← Back

Invariant Testing an ERC-4626 Vault with Foundry

William Schiller · 19 October 2026

Unit tests check the paths you thought of. Vault bugs live in the paths you didn't: a deposit after a donation, a redeem of one wei, a mint straight after a fee accrual. Invariant testing is how I find those. You describe what must always be true, and Foundry throws random sequences of calls at the vault until something breaks.

This post is the setup I start every ERC-4626 suite with: a handler, ghost variables and five invariants taken straight from the EIP-4626 spec. The code targets an OpenZeppelin ERC4626 vault, but the properties apply to any compliant vault.

What is an invariant test in Foundry?

From the Foundry docs: Forge runs random sequences of function calls against target contracts and checks every invariant_* function after each call. You control the campaign in foundry.toml:

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

runs is the number of sequences and depth is the number of calls in each. I keep fail_on_revert = false while the handler still has early returns for empty balances, so random sequences don't drown the report. Once the suite is stable I flip it to true, because the preview asserts live inside the handler and would otherwise be swallowed as discarded calls. Then I raise depth, because vault bugs often need a long setup.

Why use a handler instead of fuzzing the vault directly?

If you point Forge at the vault itself, most calls revert. Random addresses have no tokens, no approvals and no shares. The Foundry docs recommend the handler pattern: a contract that wraps the vault, bounds inputs with bound() rather than vm.assume(), pranks as a set of actors, and records ghost variables, which are totals the vault doesn't store itself.

Here is the vault under test, with a small decimals offset as OpenZeppelin recommends against inflation attacks:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
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";
 
contract MockToken is ERC20 {
    constructor() ERC20("Mock USD", "mUSD") {}
    function mint(address to, uint256 amount) external { _mint(to, amount); }
}
 
contract SimpleVault is ERC4626 {
    constructor(IERC20 asset_) ERC20("Simple Vault", "svUSD") ERC4626(asset_) {}
 
    function _decimalsOffset() internal pure override returns (uint8) {
        return 3;
    }
}

The handler

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {Test} from "forge-std/Test.sol";
import {SimpleVault, MockToken} from "../../src/SimpleVault.sol";
 
contract VaultHandler is Test {
    SimpleVault public vault;
    MockToken public token;
 
    address[] public actors;
    address internal actor;
 
    // Ghost variables: what the vault *should* hold, tracked independently.
    uint256 public ghost_deposited;
    uint256 public ghost_withdrawn;
    uint256 public ghost_donated;
 
    // Share price tracking (assets per 1 whole share).
    uint256 public ghost_lastPrice;
    bool public ghost_priceDecreased;
 
    modifier useActor(uint256 seed) {
        actor = actors[bound(seed, 0, actors.length - 1)];
        vm.startPrank(actor);
        _;
        vm.stopPrank();
        _checkPrice();
    }
 
    constructor(SimpleVault vault_, MockToken token_) {
        vault = vault_;
        token = token_;
        for (uint256 i; i < 5; i++) {
            actors.push(makeAddr(string(abi.encodePacked("actor", vm.toString(i)))));
        }
        ghost_lastPrice = _price();
    }
 
    function deposit(uint256 assets, uint256 seed) external useActor(seed) {
        assets = bound(assets, 1, 1e30);
        token.mint(actor, assets);
        token.approve(address(vault), assets);
 
        uint256 preview = vault.previewDeposit(assets);
        uint256 shares = vault.deposit(assets, actor);
 
        // EIP-4626: deposit MUST mint >= previewDeposit in the same tx.
        assertGe(shares, preview, "deposit < previewDeposit");
        ghost_deposited += assets;
    }
 
    function mint(uint256 shares, uint256 seed) external useActor(seed) {
        shares = bound(shares, 1, 1e30);
        uint256 preview = vault.previewMint(shares);
        token.mint(actor, preview);
        token.approve(address(vault), preview);
 
        uint256 assets = vault.mint(shares, actor);
 
        // EIP-4626: mint MUST pull <= previewMint assets.
        assertLe(assets, preview, "mint > previewMint");
        ghost_deposited += assets;
    }
 
    function withdraw(uint256 assets, uint256 seed) external useActor(seed) {
        uint256 max = vault.maxWithdraw(actor);
        if (max == 0) return;
        assets = bound(assets, 1, max);
 
        uint256 preview = vault.previewWithdraw(assets);
        uint256 shares = vault.withdraw(assets, actor, actor);
 
        // EIP-4626: withdraw MUST burn <= previewWithdraw shares.
        assertLe(shares, preview, "withdraw > previewWithdraw");
        ghost_withdrawn += assets;
    }
 
    function redeem(uint256 shares, uint256 seed) external useActor(seed) {
        uint256 max = vault.maxRedeem(actor);
        if (max == 0) return;
        shares = bound(shares, 1, max);
 
        uint256 preview = vault.previewRedeem(shares);
        uint256 assets = vault.redeem(shares, actor, actor);
 
        // EIP-4626: redeem MUST pay >= previewRedeem.
        assertGe(assets, preview, "redeem < previewRedeem");
        ghost_withdrawn += assets;
    }
 
    /// Round trip in one block: you must never get out more than you put in.
    function depositRedeemRoundTrip(uint256 assets, uint256 seed) external useActor(seed) {
        assets = bound(assets, 1, 1e30);
        token.mint(actor, assets);
        token.approve(address(vault), assets);
 
        uint256 shares = vault.deposit(assets, actor);
        if (shares == 0) { ghost_deposited += assets; return; }
        uint256 out = vault.redeem(shares, actor, actor);
 
        assertLe(out, assets, "free assets from deposit->redeem");
        ghost_deposited += assets;
        ghost_withdrawn += out;
    }
 
    /// Simulates yield (or a donation attack) by sending assets straight to the vault.
    function donate(uint256 assets) external {
        assets = bound(assets, 1, 1e27);
        token.mint(address(vault), assets);
        ghost_donated += assets;
        _checkPrice();
    }
 
    function actorCount() external view returns (uint256) { return actors.length; }
 
    function _price() internal view returns (uint256) {
        return vault.convertToAssets(10 ** vault.decimals());
    }
 
    function _checkPrice() internal {
        uint256 p = _price();
        if (p < ghost_lastPrice) ghost_priceDecreased = true;
        ghost_lastPrice = p;
    }
}

A few things are deliberate here:

  • Previews are checked inside the handler. The EIP-4626 guarantee is "in the same transaction", so the check has to sit right next to the call, not in an invariant that runs later.
  • maxWithdraw and maxRedeem bound the exits. That way a revert means a real problem, not a bad input.
  • donate is a first-class action. Direct transfers are how inflation attacks and accounting mismatches show up, so the fuzzer should be able to do them at any point in a sequence.

The invariant test contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {Test} from "forge-std/Test.sol";
import {SimpleVault, MockToken} from "../src/SimpleVault.sol";
import {VaultHandler} from "./handlers/VaultHandler.sol";
 
contract SimpleVaultInvariants is Test {
    SimpleVault vault;
    MockToken token;
    VaultHandler handler;
 
    function setUp() public {
        token = new MockToken();
        vault = new SimpleVault(token);
        handler = new VaultHandler(vault, token);
        targetContract(address(handler)); // fuzz the handler, never the vault directly
    }
 
    // 1. Solvency: the vault can pay everyone what it says they can redeem.
    function invariant_totalAssetsCoversRedeemable() public view {
        uint256 sum;
        for (uint256 i; i < handler.actorCount(); i++) {
            sum += vault.previewRedeem(vault.balanceOf(handler.actors(i)));
        }
        assertGe(vault.totalAssets(), sum, "redeemable > totalAssets");
        assertGe(token.balanceOf(address(vault)), vault.totalAssets(), "totalAssets > balance");
    }
 
    // 1b. Conservation: tokens in the vault match the ghost ledger.
    function invariant_conservation() public view {
        assertEq(
            token.balanceOf(address(vault)),
            handler.ghost_deposited() + handler.ghost_donated() - handler.ghost_withdrawn(),
            "asset ledger mismatch"
        );
    }
 
    // 2. Share price never falls when the vault takes no losses.
    function invariant_sharePriceMonotonic() public view {
        assertFalse(handler.ghost_priceDecreased(), "share price decreased");
    }
 
    // 3 + 5. Conversions round down (EIP-4626), so a round trip never gains.
    function invariant_conversionsRoundDown() public view {
        uint256 a = 1e18;
        uint256 s = 1e21;
        assertLe(vault.convertToAssets(vault.convertToShares(a)), a, "assets round trip gained");
        assertLe(vault.convertToShares(vault.convertToAssets(s)), s, "shares round trip gained");
    }
 
    // 3b. Paying for shares must cost at least as much as redeeming them returns.
    function invariant_mintCostsAtLeastRedeemPays() public view {
        uint256 s = 1e21;
        assertGe(vault.previewMint(s), vault.previewRedeem(s), "mint cheaper than redeem");
    }
}

The five invariants, and where they come from

1. totalAssets covers everything users can redeem

This is solvency, the "Solvency" row in Foundry's table of common invariants. If the sum of previewRedeem(balanceOf(user)) is ever greater than totalAssets(), the last person out can't be paid. In a real vault with strategies, swap the token balance check for whatever backs totalAssets(). That's often where the bug turns out to be.

2. Share price is monotonic when there are no losses

With no losses, fees or slashing in the mock, assets per share should never fall. Deposits and mints round in the vault's favour, so they can only nudge the price up. Redeems pay out rounded down, so they can't push it down either. Donations push it up. If the price ever drops, someone took value from the remaining holders. In a real vault you add a loss action to the handler and only enforce monotonicity across sequences without one.

3. No free shares: rounding favours the vault

EIP-4626's security section says to round down when issuing shares for assets or paying assets for shares, and up when calculating the shares or assets a user must supply. The depositRedeemRoundTrip action and invariant_mintCostsAtLeastRedeemPays turn that sentence into code: no sequence should let a user get out more than they put in.

4. previewX matches X

The spec's wording is precise. deposit returns at least previewDeposit, mint pulls at most previewMint, withdraw burns at most previewWithdraw, and redeem pays at least previewRedeem. Integrators rely on these numbers for slippage checks. A preview that over-promises, often because it forgets a fee, breaks every router that uses it.

5. convertTo* rounds down

EIP-4626 says both convertToShares and convertToAssets "MUST round down towards 0". Rounding down in both directions means a round trip can never gain, which is what invariant_conversionsRoundDown checks.

One caveat: the spec lets convertTo* be estimates, for example time-weighted. In a vault that does that, check this invariant against the vault's own documented formula rather than a round trip.

How do I make the fuzzer find real bugs?

  • Log a call summary. The Foundry docs show a callSummary() pattern. If half your calls return early because maxRedeem was zero, the suite is testing much less than you think.
  • Use afterInvariant() for expensive checks. Foundry runs it once at the end of each run instead of after every call, which suits loops over many actors.
  • Fuzz time. Recent Foundry versions support max_time_delay and max_block_delay, so time can pass between calls. Fee accrual and NAV updates need this.
  • Try optimisation mode. If an invariant returns int256 instead of asserting, Foundry tries to maximise that value. I use it to search for the largest rounding error a user can extract.
  • Break it on purpose. Change one rounding direction to Ceil, or make previewRedeem skip a fee, and confirm the suite fails. A suite that can't catch a seeded bug proves nothing.

What these invariants don't cover

These five are the baseline. Real vaults need more. Async redemption vaults (ERC-7540) need invariants over pending and claimable amounts. I wrote about where those prices get fixed in How ERC-7540 Vaults Price Redemptions. Vaults priced from an off-chain NAV need tests for stale updates, which I cover in Stale Share Pricing in Async RWA Vaults. And invariants don't replace reading the code. They find the bugs that line-by-line reading misses, and the review finds what you can't express as an invariant.

Want an invariant suite for your vault?

I write handler-based Foundry invariant suites for ERC-4626 and ERC-7540 vaults, at a fixed price. They go into your repo and run in your CI, so every later change gets the same checks. They also pair with a pre-audit review. If you're shipping vault code, tell me what you're building.


Sources: EIP-4626, Foundry invariant testing docs, OpenZeppelin ERC-4626 docs.