Invariant Testing with Foundry: Making Assumptions Executable

Every protocol rests on a handful of properties that must always hold. Writing them down as invariant tests turns an auditor's mental model into something a machine can attack.

Published by Xevry

Unit tests check that specific inputs produce specific outputs. Invariant tests check that a property holds no matter what sequence of actions occurs. For DeFi protocols — where the dangerous bugs usually emerge from unexpected combinations of calls — that difference is significant.

What makes a good invariant

An invariant is a statement about system state that must be true after any valid sequence of transactions. Good ones come straight from the protocol's accounting:

  • The vault's token balance is at least the sum of all users' redeemable assets.
  • Total supply of shares equals the sum of individual share balances.
  • Total borrowed never exceeds total supplied.
  • No user can withdraw more than they deposited plus their accrued share of yield.

The handler pattern

Foundry's invariant fuzzer calls random functions on target contracts with random arguments. Pointed directly at a protocol, most calls revert on invalid input and the run explores very little. A handler contract sits in between: it bounds inputs into valid ranges, manages actors, and tracks ghost variables that record what the protocol state should be.

contract VaultHandler is Test {    Vault vault;    MockERC20 token;    uint256 public ghostDeposited;    uint256 public ghostWithdrawn;     function deposit(uint256 amount) external {        amount = bound(amount, 1, 1e30);        token.mint(address(this), amount);        token.approve(address(vault), amount);        vault.deposit(amount, address(this));        ghostDeposited += amount;    }     function redeem(uint256 shares) external {        shares = bound(shares, 0, vault.balanceOf(address(this)));        ghostWithdrawn += vault.redeem(shares, address(this), address(this));    }}

The test contract then targets the handler and asserts properties after every call sequence:

contract VaultInvariants is Test {    Vault vault;    MockERC20 token;    VaultHandler handler;     function setUp() public {        // deploy vault, token and handler ...        targetContract(address(handler));    }     function invariant_solvent() public view {        assertGe(            token.balanceOf(address(vault)),            vault.convertToAssets(vault.totalSupply())        );    }     function invariant_noValueCreated() public view {        assertLe(handler.ghostWithdrawn(), handler.ghostDeposited());    }}

Tuning the run

In foundry.toml, the [invariant] section controls how hard the fuzzer works. runs sets the number of independent sequences, depth the number of calls per sequence, and fail_on_revert whether an unexpected revert counts as a failure. Increase depth for protocols where bugs require long setup sequences, such as interest accrual over many blocks.

[invariant]runs = 512depth = 64fail_on_revert = false
Where this fits in an auditInvariant tests complement manual review; they do not replace it. The fuzzer only checks the properties you write, so the value comes from a reviewer who understands the protocol well enough to state what must never happen. We hand these suites back to the team, where they keep paying off long after the engagement ends.

Common pitfalls

  • Over-bounding inputs so the fuzzer never reaches edge cases like zero amounts or maximum values.
  • A single actor. Many bugs only appear when multiple users interact. Use several actor addresses in the handler.
  • Ignoring time. Use vm.warp and vm.roll inside handlers so time-dependent logic is exercised.
  • Weak invariants. An assertion that is trivially true catches nothing. Write the strongest statement the protocol actually guarantees.
All research Next: Read-Only Reentrancy: When View Functions Lie
Start a conversation

Let's discuss your security needs.

Tell us what you're building. We'll respond with clear questions, an honest view of whether we're the right fit, and a scoped proposal if we are.