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 = falseCommon 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.warpandvm.rollinside 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.