Green blocks travel through a transparent vault along cobalt paths, representing actions that change shared protocol state.

In the previous Foundry tutorial, we wrote ERC-20 tests entirely in Solidity. We picked the users, made a few calls and checked the result. Nice and civilized.

Then Carol shows up.

Alice deposits into a vault. Bob adds some assets. Alice sends half her shares to Carol, who redeems them, receives more shares and redeems again. Someone donates tokens directly to the vault. Of course they do.

We could write that exact test. But who is going to think of all the other orders, amounts and balances? This is where invariant testing helps: we give Forge a set of actions and properties to check, and let it mix things up.

My hand-written test: Alice and Bob queue politely at a vault. The sequence fuzzer: Alice, Bob and Carol exchange tokens in a tangle of arrows while a robot keeps the receipts.

So let's build it. One small vault, three users, four actions, and an accountant who actually keeps the receipts.

1. What are we actually testing?

  • Regular test: we choose the scenario.
  • Fuzz test: Forge varies the test arguments. The test body can already make several contract calls.
  • Invariant test: Forge generates sequences of calls to our targets and checks properties along the way.

Each run starts from our setup state. runs controls how many sequences Forge tries; depth controls how long each sequence can get.

We keep check_interval = 1, the default, to check after every generated call. The configuration reference describes the other options.

First, give the accountant a job

For this example, we want to know:

  • Do the vault's assets match the deposits and donations we made, minus the assets users received back?
  • Does its share supply match the shares issued and burned?
  • Does each user still own the shares our test expects them to own, including after transfers?

These are accounting properties. A passing test tells us about these checks within the sequences we explored. It doesn't turn a questionable yield strategy into a good idea. Sadly, there is no cheatcode for that.

2. Start with a vault we can reason about

We will use OpenZeppelin's ERC-4626 implementation.

Our vault holds its assets directly. The test token transfers exactly the amount requested. We have enough moving parts already; the rebasing token can sit this one out.

Put the following contracts in test/VaultInvariant.t.sol. The unrestricted mint function is just for funding test users.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";

// Test-only token. This unrestricted mint function is not for production.
contract MockAsset is ERC20 {
    constructor() ERC20("Test Dollar", "TUSD") {}

    function decimals() public pure override returns (uint8) {
        return 6;
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

contract ExampleVault is ERC20, ERC4626 {
    constructor(IERC20 asset_)
        ERC20("Example Vault", "vTUSD")
        ERC4626(asset_)
    {}

    function decimals() public view override(ERC20, ERC4626) returns (uint8) {
        return ERC4626.decimals();
    }
}

The imports assume an @openzeppelin/contracts/ remapping to lib/openzeppelin-contracts/contracts/. The rest of the example goes in this same test file.

Don't ask the vault to mark its own homework

OpenZeppelin's default totalAssets() returns the vault's token balance. Comparing those two values only checks that the getter returns what it was written to return!

To catch incorrect accounting, we need a separate record of what should be there.

3. Give the fuzzer a handler

Calling the vault directly lets Forge pick arbitrary recipients, owners and amounts. Many calls would fail immediately: no tokens, no approval, no shares. Our fuzzer would spend the afternoon trying to withdraw money from an empty wallet. Relatable, but not very useful for this test.

A handler gets each action ready: choose a user, fund a deposit, set an approval, call the vault. Foundry's handler guide covers the pattern.

Forge chooses a handler action. The action updates the vault and a separate expected ledger, then the invariant compares the two.

Let's give ours three users and a small accounting model:

solidity
contract VaultHandler is Test {
    MockAsset public asset;
    ExampleVault public vault;
    address[3] public actors;
    address public donor;

    uint256 public expectedAssets;
    uint256 public expectedSupply;
    mapping(address => uint256) public expectedShares;
    mapping(bytes4 => uint256) public completed;
    mapping(bytes4 => uint256) public skipped;

    constructor(ExampleVault vault_, MockAsset asset_) {
        vault = vault_;
        asset = asset_;
        actors = [makeAddr("alice"), makeAddr("bob"), makeAddr("carol")];
        donor = makeAddr("donor");
    }

    function _actor(uint256 seed) internal view returns (address) {
        return actors[seed % actors.length];
    }
}

Here is our ledger:

  • expectedAssets: assets in, minus assets out.
  • expectedSupply: shares issued, minus shares burned.
  • expectedShares: each user's expected share balance.

These are ghost variables. They live in the test, not the production contracts. The ghost's job is bookkeeping. Not every haunting gets to be exciting.

completed and skipped count what the handler actually did. Calling a function that immediately returns still counts as a call in Foundry's selector metrics. We'll come back to that little trap.

For now, Alice, Bob and Carol are the only share recipients. Summing their balances only works if we really track every holder. Add a fee recipient later, and it needs a place in the ledger too.

4. Add deposits and redemptions

Now add this deposit function inside VaultHandler:

solidity
    function deposit(uint256 actorSeed, uint256 amount) external {
        address user = _actor(actorSeed);
        amount = bound(amount, 1, 1_000_000e6);
        asset.mint(user, amount);

        uint256 assetsBefore = asset.balanceOf(user);
        uint256 preview = vault.previewDeposit(amount);

        vm.startPrank(user);
        asset.approve(address(vault), amount);
        uint256 shares = vault.deposit(amount, user);
        vm.stopPrank();

        assertEq(shares, preview, "deposit preview");
        assertEq(asset.balanceOf(user), assetsBefore - amount, "deposit debit");

        expectedAssets += amount;
        expectedSupply += shares;
        expectedShares[user] += shares;
        completed[this.deposit.selector]++;
    }

Three details are doing most of the work here:

  1. bound maps the input between one base unit and one million whole tokens. We get a usable amount instead of rejecting most inputs with a broad vm.assume filter.
  2. mint gives the user additional tokens. deal(token, user, amount) sets their balance to amount; it can overwrite tokens from an earlier redemption. See forge-std's implementation.
  3. After the deposit, we update our ledger from the supplied amount and returned shares. We never rebuild it by reading vault.totalAssets().

The preview immediately before the call should also match the returned shares for this unmodified, fee-free implementation.

And now, give the money back

Redemptions work in the other direction. Add this function to the same handler:

solidity
    function redeem(uint256 actorSeed, uint256 shares) external {
        address user = _actor(actorSeed);
        uint256 available = expectedShares[user];
        if (available == 0) {
            skipped[this.redeem.selector]++;
            return;
        }

        shares = bound(shares, 1, available);
        uint256 assetsBefore = asset.balanceOf(user);
        uint256 preview = vault.previewRedeem(shares);

        vm.prank(user);
        uint256 assets = vault.redeem(shares, user, user);

        uint256 received = asset.balanceOf(user) - assetsBefore;
        assertEq(assets, preview, "redeem preview");
        assertEq(received, assets, "redeem credit");

        expectedAssets -= received;
        expectedSupply -= shares;
        expectedShares[user] -= shares;
        completed[this.redeem.selector]++;
    }

No shares? Record a skip. Otherwise, redeem some of the user's expected shares and subtract the assets that actually arrive in their wallet.

Using the expected balance matters. If the vault loses track of Carol's shares, we want a failed test. We don't want the handler helpfully lowering her withdrawal and pretending everything is fine.

A quick preview caveat

ERC-4626 gives directional guarantees for previews and execution. Our exact equality checks fit this implementation; they aren't a rule for every vault. Neither is adding a mysterious “plus one wei” until the test goes green.

Virtual assets, virtual shares and decimal offsets also mean zero supply needn't imply a one-to-one conversion. And preview and execution can share a pricing mistake. Custom pricing needs its own independent model.

5. Let users interact with each other's state

Vault shares are ERC-20 tokens. Carol doesn't need to deposit anything if Alice sends her shares to redeem. We should test that too.

And yes, people can send tokens straight to the vault. “Nobody would do that” is an excellent way to invite the fuzzer to do exactly that.

Let's add donations and share transfers:

solidity
    function donate(uint256 amount) external {
        amount = bound(amount, 1, 100_000e6);
        asset.mint(donor, amount);

        vm.prank(donor);
        assertTrue(asset.transfer(address(vault), amount));

        expectedAssets += amount;
        completed[this.donate.selector]++;
    }

    function transferShares(
        uint256 fromSeed,
        uint256 toSeed,
        uint256 shares
    ) external {
        address from = _actor(fromSeed);
        address to = _actor(toSeed);
        uint256 available = expectedShares[from];
        if (available == 0) {
            skipped[this.transferShares.selector]++;
            return;
        }

        shares = bound(shares, 1, available);
        vm.prank(from);
        assertTrue(vault.transfer(to, shares));

        expectedShares[from] -= shares;
        expectedShares[to] += shares;
        completed[this.transferShares.selector]++;
    }
  • Donation: assets increase; no new shares.
  • Share transfer: ownership changes; assets and total supply stay the same.

We even allow a transfer to yourself. The subtraction and addition cancel out. Congratulations, Bob, you still own your shares.

Now Forge has four actions to shuffle. The handler makes them executable, and the ledger keeps track of their effects.

6. Connect the handler and check the accounting

Next, add the test contract. We target the handler and explicitly whitelist its four action selectors:

solidity
contract VaultInvariantTest is Test {
    MockAsset internal asset;
    ExampleVault internal vault;
    VaultHandler internal handler;

    function setUp() public {
        asset = new MockAsset();
        vault = new ExampleVault(IERC20(address(asset)));
        handler = new VaultHandler(vault, asset);

        targetContract(address(handler));

        bytes4[] memory selectors = new bytes4[](4);
        selectors[0] = VaultHandler.deposit.selector;
        selectors[1] = VaultHandler.redeem.selector;
        selectors[2] = VaultHandler.donate.selector;
        selectors[3] = VaultHandler.transferShares.selector;
        targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
    }
}

Every movement our ledger tracks must go through the handler. We don't want Forge calling the mock's mint independently and bypassing our books. The StdInvariant helpers provide targetContract and targetSelector for this setup.

Now put the assertions inside VaultInvariantTest:

solidity
    function invariant_accounting() public view {
        _assertAccounting();
    }

    function _assertAccounting() internal view {
        assertEq(asset.balanceOf(address(vault)), handler.expectedAssets(), "asset ledger");
        assertEq(vault.totalAssets(), handler.expectedAssets(), "reported assets");
        assertEq(vault.totalSupply(), handler.expectedSupply(), "share supply");

        uint256 sum;
        for (uint256 i; i < 3; ++i) {
            address user = handler.actors(i);
            uint256 balance = vault.balanceOf(user);
            assertEq(balance, handler.expectedShares(user), "user shares");
            sum += balance;
        }
        assertEq(sum, vault.totalSupply(), "untracked shares");
    }

We compare the ledger against the actual token balance, reported assets, share supply and each user's shares. Finally, we check that our three users account for all outstanding shares.

One invariant entrypoint keeps the related checks together. The helper will also be useful when everyone wants their money back.

Keep the books complete. New fees, recipients or asset movements need matching model updates. Otherwise, the test can complain about perfectly correct behavior. The ghost is only as good as its paperwork.

7. Check that users can exit

Eventually, even Carol has had enough. At the end of each sequence, all three users should be able to redeem their remaining shares.

Foundry provides afterInvariant() for a check at the end of each successful run. Add this function to VaultInvariantTest:

solidity
    function afterInvariant() public {
        for (uint256 i; i < 3; ++i) {
            address user = handler.actors(i);
            uint256 shares = handler.expectedShares(user);
            if (shares != 0) handler.redeem(i, shares);
        }

        _assertAccounting();
        assertEq(vault.totalSupply(), 0, "exit leaves shares");
    }

The hook runs once per successful run. A failed redemption or assertion fails the test; its state changes are discarded afterwards. See the hook documentation.

We require zero remaining shares. Assets may remain because of donations, rounding and virtual-share accounting. A vault with a lockup or withdrawal queue needs an exit check that follows those rules.

8. Run a campaign that does useful work

Time to let Forge loose. Start with:

Terminal / configuration
[profile.default.invariant]
runs = 256
depth = 64
fail_on_revert = true
check_interval = 1
show_metrics = true
shrink_run_limit = 5000
failure_persist_dir = "cache/invariant"

More runs explores more sequences. More depth makes them longer. Neither number measures how secure the vault is. We still need useful actions and useful checks.

These handler actions should succeed, so fail_on_revert = true makes an unexpected revert fail the test. Test invalid inputs and unauthorized callers separately, or explicitly check their expected reverts in dedicated actions.

Run the test with traces for failures:

Terminal / configuration
forge test --match-contract VaultInvariantTest -vvv

Green doesn't always mean busy

The test passed: a developer celebrates a green check. The handler did nothing: the robot is asleep beside an untouched vault, with a clipboard reading completed zero.

A thousand calls to redeem may be a thousand early returns. The test passed. The handler took a lovely holiday.

Check the selector metrics and our completed / skipped counters. If redemptions rarely happen, add a campaign starting with funded positions. Keep the empty-vault campaign too; it explores a different starting state.

Keep the interesting failures

Foundry tries to shrink a failing sequence and saves a counterexample in the failure directory. Rerunning the same test replays saved failures. Resist the urge to immediately delete the cache. That annoying failure is now a useful regression.

Record the Foundry version, dependency revisions, compiler settings and seed alongside it.

You can also reuse inputs that reached new execution edges. Enable a coverage corpus under the same invariant configuration:

Terminal / configuration
corpus_dir = "cache/invariant-corpus"

The corpus helps explore code; saved failures reproduce broken properties. Foundry's corpus guide covers replay and minimization. Keep the useful artifacts in CI so they survive a fresh runner.

What this example actually ran

The complete example passed on Foundry with seed 0x5eed: 1,024 runs × 128 depth, with zero reverts. The accounting checks and end-of-run redemption hook passed. Those 131,072 generated calls include early returns; they are not 131,072 completed actions.

Happy Solidity coding!