How we structure Foundry invariant tests for complex contracts
Exploits do not happen on happy paths. Attackers chain state transitions across blocks, flash loans and unexpected callers. Handler-based invariant fuzzing is how you find those sequences before an auditor — or an adversary — does.
Handler-based fuzzing · invariant verification · state machine safety
Unit tests verify expected happy paths. Integration tests verify linear user journeys. Mainnet exploits occur in neither. Attackers chain state transitions across multiple blocks, flash loans and unexpected account interactions to force contracts into mathematical states nobody considered.
We treat unit testing as necessary but insufficient. Before code goes to an external auditor, it goes through handler-based invariant fuzzing in Foundry.
The spectrum of testing rigour
| Methodology | Input scope | State depth | Exploit detection |
|---|---|---|---|
| Unit testing | Static, fixed inputs | Single call | Low — verifies only known logic |
| Standard fuzzing | Randomised inputs | Single call | Medium — finds overflow and boundary bugs |
| Handler invariant fuzzing | Randomised inputs and sequences | Multi-step state chains | High — uncovers hidden state corruption |
Bounding the fuzzer with a handler
An invariant is a condition that must hold at all times, whatever sequence of calls random actors execute. Left unbounded, a fuzzer spends its budget calling functions with arguments that revert immediately and proves nothing. A handler contract bounds inputs into the valid domain so the fuzzer explores states that can actually occur.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import "forge-std/Test.sol";
import "forge-std/StdInvariant.sol";
import "../src/StondelVault.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// --- HANDLER ---
contract VaultHandler is Test {
StondelVault public vault;
ERC20 public asset;
address[] public users;
uint256 public totalDepositedGhost;
constructor(StondelVault _vault, ERC20 _asset) {
vault = _vault;
asset = _asset;
users.push(address(0x1001));
users.push(address(0x1002));
users.push(address(0x1003));
}
function deposit(uint256 userIndex, uint256 amount) external {
address user = users[userIndex % users.length];
amount = bound(amount, 1000, 1_000_000 * 1e18);
deal(address(asset), user, amount);
vm.startPrank(user);
asset.approve(address(vault), amount);
vault.deposit(amount, user);
vm.stopPrank();
totalDepositedGhost += amount;
}
function withdraw(uint256 userIndex, uint256 shareAmount) external {
address user = users[userIndex % users.length];
uint256 userShares = vault.balanceOf(user);
if (userShares == 0) return;
shareAmount = bound(shareAmount, 1, userShares);
vm.startPrank(user);
vault.redeem(shareAmount, user, user);
vm.stopPrank();
}
}
// --- INVARIANT TEST ---
contract VaultInvariantTest is StdInvariant, Test {
StondelVault public vault;
ERC20 public asset;
VaultHandler public handler;
function setUp() public {
asset = new MockERC20("Mock Token", "MCK");
vault = new StondelVault(asset);
handler = new VaultHandler(vault, asset);
targetContract(address(handler));
}
function invariant_solvency() public view {
uint256 totalAssets = asset.balanceOf(address(vault));
uint256 totalShares = vault.totalSupply();
if (totalShares > 0) {
assertGe(totalAssets, totalShares, "SOLVENCY_VIOLATED: vault insolvent");
}
}
}Running forge test --match-contract VaultInvariantTest executes tens of thousands of random sequential calls across multiple accounts. The ghost variable in the handler is what lets you assert against an independently tracked total rather than trusting the contract to audit itself.
A bounded handler is the difference between a fuzzer that explores your protocol and one that bounces off its input validation.