2025-09-29·15 min read

Foundry invariant testing beyond the basics: handlers, actors, and bounded runs

Foundry Invariant Testing Beyond the Basics: Handlers, Actors, and Bounded Runs

By deivitto

You've got forge test running. Maybe you've even written an invariant_ function or two. But if you're still writing invariants that just call the target contract directly, you're leaving a lot of bugs on the table.

Foundry's invariant testing engine is more powerful than most people realize. With the right setup (handlers, actor management, input bounding, and ghost variables) you can catch bugs that basic fuzz tests won't ever reach. Let's go beyond the basics.

The problem with direct target testing

Here's what most people start with:

contract BasicInvariantTest is Test {
    Vault vault;

    function setUp() public {
        vault = new Vault(address(new MockERC20()));
    }

    function invariant_totalAssetsGteTotalSupply() public {
        assertGe(vault.totalAssets(), vault.totalSupply());
    }
}

This works, technically. Foundry will call random functions on the Vault with random inputs. But here's the problem: most of those calls will revert. The fuzzer will spend 90% of its budget hitting deposit(0) or withdraw(amountGreaterThanBalance) and learning nothing.

You need handlers.

Handler contracts: guiding the fuzzer

A handler contract wraps your target and constrains inputs to meaningful ranges. Think of it as a translator between "random bytes the fuzzer generates" and "valid protocol interactions."

contract VaultHandler is Test {
    Vault vault;
    MockERC20 token;

    address[] public actors;
    address currentActor;

    // Ghost variables
    mapping(address => uint256) public ghost_depositSum;
    uint256 public ghost_totalDeposited;
    uint256 public ghost_totalWithdrawn;

    modifier useActor(uint256 actorIndexSeed) {
        currentActor = actors[bound(actorIndexSeed, 0, actors.length - 1)];
        vm.startPrank(currentActor);
        _;
        vm.stopPrank();
    }

    constructor(Vault _vault, MockERC20 _token) {
        vault = _vault;
        token = _token;

        // Create actors
        actors.push(address(0xBEEF));
        actors.push(address(0xCAFE));
        actors.push(address(0xDEAD));

        // Fund and approve
        for (uint256 i = 0; i < actors.length; i++) {
            token.mint(actors[i], 100_000e18);
            vm.prank(actors[i]);
            token.approve(address(vault), type(uint256).max);
        }
    }

    function deposit(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
        amount = bound(amount, 1, token.balanceOf(currentActor));

        vault.deposit(amount, currentActor);

        ghost_depositSum[currentActor] += amount;
        ghost_totalDeposited += amount;
    }

    function withdraw(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
        uint256 maxWithdraw = vault.maxWithdraw(currentActor);
        if (maxWithdraw == 0) return;

        amount = bound(amount, 1, maxWithdraw);

        vault.withdraw(amount, currentActor, currentActor);

        ghost_totalWithdrawn += amount;
    }

    function redeem(uint256 actorSeed, uint256 shares) public useActor(actorSeed) {
        uint256 maxRedeem = vault.maxRedeem(currentActor);
        if (maxRedeem == 0) return;

        shares = bound(shares, 1, maxRedeem);

        vault.redeem(shares, currentActor, currentActor);

        ghost_totalWithdrawn += vault.previewRedeem(shares);
    }
}

See the difference? Every call to deposit will actually succeed because we bound the amount to something the actor can afford. Every withdraw checks the max first. The fuzzer spends its budget on interesting state transitions instead of reverts.

The bound() helper

Foundry's bound() function is your best friend. It takes a random value and maps it into a range:

// bound(value, min, max) -> value in [min, max]
uint256 amount = bound(rawAmount, 1, 1_000_000e18);

A few tips:

  • Don't bound to zero. Most protocols treat zero amounts as no-ops or reverts. Start at 1.
  • Use realistic ranges. Bounding to type(uint256).max is technically valid but wastes cycles on unrealistic scenarios.
  • For percentages or basis points, bound to the actual range: bound(bps, 1, 10_000).

Setting up the test contract

Your main test contract points Foundry at the handler:

contract VaultInvariantTest is Test {
    Vault vault;
    MockERC20 token;
    VaultHandler handler;

    function setUp() public {
        token = new MockERC20("Token", "TKN", 18);
        vault = new Vault(token);

        handler = new VaultHandler(vault, token);

        // Tell Foundry to only call the handler
        targetContract(address(handler));
    }

    function invariant_solvency() public {
        assertGe(
            token.balanceOf(address(vault)),
            vault.totalAssets()
        );
    }

    function invariant_ghostTracking() public {
        assertEq(
            handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn(),
            vault.totalAssets()
        );
    }

    function invariant_sharesConsistency() public {
        uint256 totalShares;
        for (uint256 i = 0; i < handler.actorsCount(); i++) {
            totalShares += vault.balanceOf(handler.actors(i));
        }
        assertEq(totalShares, vault.totalSupply());
    }
}

The key line is targetContract(address(handler)). Without it, Foundry calls random functions on every contract it can see — including the token, which would mess up your invariants.

excludeContract and excludeSelector

Sometimes you need fine-grained control over what the fuzzer touches:

function setUp() public {
    // ... deploy everything ...

    // Only fuzz through the handler
    targetContract(address(handler));

    // Don't let the fuzzer call these directly
    excludeContract(address(vault));
    excludeContract(address(token));

    // Exclude specific functions from the handler
    bytes4[] memory selectors = new bytes4[](1);
    selectors[0] = handler.emergencyWithdraw.selector;
    excludeSelector(FuzzSelector({
        addr: address(handler),
        selectors: selectors
    }));
}

Use excludeSelector when your handler has admin functions that would create unrealistic scenarios. If the fuzzer can call pause() at any time, it'll spend half its budget in the paused state and miss bugs in normal operation.

fail_on_revert, the silent bug killer

Here's a config option that changes everything. In your foundry.toml:

[invariant]
runs = 256
depth = 100
fail_on_revert = false   # default

With fail_on_revert = false (the default), the fuzzer swallows reverts and moves on. This is fine when you're using handlers that already bound inputs. But sometimes you want to know about reverts.

Set fail_on_revert = true when:

  • Your handlers are well-crafted and reverts indicate real bugs
  • You're testing that certain operations should never revert under valid inputs
  • You want to catch "function reverts when it shouldn't" bugs

Keep it false when:

  • You're still iterating on handlers
  • You don't have good input bounding yet
  • Your protocol has functions that legitimately revert in many states

I usually start with false, get my handlers solid, then flip to true for a final campaign.

Actor management patterns

The modifier pattern I showed earlier works great, but there's a more advanced version for protocols with role-based access:

contract AdvancedHandler is Test {
    mapping(uint8 => address[]) actorsByRole;

    uint8 constant ROLE_DEPOSITOR = 0;
    uint8 constant ROLE_ADMIN = 1;
    uint8 constant ROLE_LIQUIDATOR = 2;

    function _getActorByRole(uint8 role, uint256 seed) internal view returns (address) {
        address[] storage pool = actorsByRole[role];
        return pool[bound(seed, 0, pool.length - 1)];
    }

    function adminSetFee(uint256 actorSeed, uint256 newFee) public {
        address admin = _getActorByRole(ROLE_ADMIN, actorSeed);
        newFee = bound(newFee, 0, 1000); // max 10%

        vm.prank(admin);
        vault.setFee(newFee);
    }

    function userDeposit(uint256 actorSeed, uint256 amount) public {
        address user = _getActorByRole(ROLE_DEPOSITOR, actorSeed);
        // ... deposit logic ...
    }
}

This way, admin functions are only called by admin addresses, and user functions by user addresses. The fuzzer still controls which admin and what parameters — you just prevent impossible scenarios like a random address calling an onlyOwner function.

Ghost variables: your parallel accounting system

Ghosts track what the state should be. Here's a more complete pattern:

// Track every deposit and withdrawal per user
mapping(address => uint256) public ghost_userDeposited;
mapping(address => uint256) public ghost_userWithdrawn;
uint256 public ghost_yieldAccrued;

// Call counter -- useful for debugging
uint256 public ghost_depositCalls;
uint256 public ghost_withdrawCalls;

function deposit(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
    // ... bounded deposit ...
    ghost_userDeposited[currentActor] += amount;
    ghost_depositCalls++;
}

Then your invariants can check:

function invariant_accounting() public {
    uint256 expectedBalance = handler.ghost_totalDeposited()
        - handler.ghost_totalWithdrawn()
        + handler.ghost_yieldAccrued();

    // Allow 1 wei tolerance for rounding
    assertApproxEqAbs(
        token.balanceOf(address(vault)),
        expectedBalance,
        handler.ghost_depositCalls() // at most 1 wei per deposit
    );
}

That tolerance calculation is important. Rounding errors accumulate. If you've had 50 deposits, each might round down by 1 wei, so you allow 50 wei of drift. This catches real accounting bugs while ignoring expected rounding.

Running campaigns with different depths

Not all invariant campaigns should look the same. Short runs catch shallow bugs fast. Long runs find deep state-dependent issues.

# foundry.toml - quick check (CI)
[profile.ci.invariant]
runs = 64
depth = 50

# foundry.toml - standard (development)
[profile.default.invariant]
runs = 256
depth = 100

# foundry.toml - deep (pre-audit)
[profile.deep.invariant]
runs = 1024
depth = 500

Run them with FOUNDRY_PROFILE=deep forge test. I always run the deep profile before shipping anything to production.

Foundry vs Echidna vs Medusa

Here's the honest comparison. I use all three, and they each have strengths.

Foundry's invariant testing:

  • You're already using Foundry, so zero extra setup
  • Great Solidity-native DX, handlers are just contracts
  • bound() and vm.prank() make actor patterns clean
  • Decent for quick checks and CI integration
  • Limited in call sequence depth compared to dedicated fuzzers

Echidna:

  • Coverage-guided, it learns which inputs explore new code paths
  • Better at finding deep bugs that require specific call sequences
  • Config-driven, less Solidity boilerplate
  • Slower per run but smarter about exploration

Medusa:

  • Also coverage-guided, built on Go
  • Faster than Echidna for large contracts
  • Parallel execution out of the box
  • Growing feature set

My recommendation: start with Foundry because it's the easiest to set up. Once your properties are solid, run the same logic through Echidna or Medusa for deeper exploration. The Chimera framework makes this switch nearly painless, same properties, different runners. Check out the full comparison for details.

Debugging failing invariants

When an invariant fails, Foundry shows you the call sequence. But sequences can be long and hard to parse. A few debugging tricks:

  1. Add event logging to handlers. Emit events in every handler function with the actor and parameters. Then check the trace.
  2. Use ghost counters. If you know the invariant fails after ghost_depositCalls == 3, you can narrow down the sequence.
  3. Write regression tests. Once you've found a failing sequence, write it as a regular test_ function so it never regresses.
  4. Shrink manually. If the sequence is 50 calls, try removing calls from the middle and see if it still fails. Most bugs need 3-5 specific calls.

Where to go from here

If you want to write properties that work across Foundry, Echidna, and Medusa, look into the Chimera framework. If you want to see how invariant testing fits into a broader security strategy, the comparison between Foundry, Echidna, and Medusa covers the trade-offs.

And if you're working on a DeFi protocol and want help setting up a proper invariant suite, we've done this for dozens of teams.

Get a Security Review


deivitto builds security tooling and audits DeFi protocols at Recon. He's run more invariant campaigns than he can count and still finds new bugs every week.

Related Posts

Related Glossary Terms

Take your Foundry testing to the next level