Foundry invariant testing beyond the basics: handlers, actors, and bounded runs
Foundry Invariant Testing Beyond the Basics: Handlers, Actors, and Bounded Runs
By deivitto
You've got forge test running. Maybe you've even written an invariant_ function or two. But if you're still writing invariants that just call the target contract directly, you're leaving a lot of bugs on the table.
Foundry's invariant testing engine is more powerful than most people realize. With the right setup (handlers, actor management, input bounding, and ghost variables) you can catch bugs that basic fuzz tests won't ever reach. Let's go beyond the basics.
The problem with direct target testing
Here's what most people start with:
contract BasicInvariantTest is Test {
Vault vault;
function setUp() public {
vault = new Vault(address(new MockERC20()));
}
function invariant_totalAssetsGteTotalSupply() public {
assertGe(vault.totalAssets(), vault.totalSupply());
}
}
This works, technically. Foundry will call random functions on the Vault with random inputs. But here's the problem: most of those calls will revert. The fuzzer will spend 90% of its budget hitting deposit(0) or withdraw(amountGreaterThanBalance) and learning nothing.
You need handlers.
Handler contracts: guiding the fuzzer
A handler contract wraps your target and constrains inputs to meaningful ranges. Think of it as a translator between "random bytes the fuzzer generates" and "valid protocol interactions."
contract VaultHandler is Test {
Vault vault;
MockERC20 token;
address[] public actors;
address currentActor;
// Ghost variables
mapping(address => uint256) public ghost_depositSum;
uint256 public ghost_totalDeposited;
uint256 public ghost_totalWithdrawn;
modifier useActor(uint256 actorIndexSeed) {
currentActor = actors[bound(actorIndexSeed, 0, actors.length - 1)];
vm.startPrank(currentActor);
_;
vm.stopPrank();
}
constructor(Vault _vault, MockERC20 _token) {
vault = _vault;
token = _token;
// Create actors
actors.push(address(0xBEEF));
actors.push(address(0xCAFE));
actors.push(address(0xDEAD));
// Fund and approve
for (uint256 i = 0; i < actors.length; i++) {
token.mint(actors[i], 100_000e18);
vm.prank(actors[i]);
token.approve(address(vault), type(uint256).max);
}
}
function deposit(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
amount = bound(amount, 1, token.balanceOf(currentActor));
vault.deposit(amount, currentActor);
ghost_depositSum[currentActor] += amount;
ghost_totalDeposited += amount;
}
function withdraw(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
uint256 maxWithdraw = vault.maxWithdraw(currentActor);
if (maxWithdraw == 0) return;
amount = bound(amount, 1, maxWithdraw);
vault.withdraw(amount, currentActor, currentActor);
ghost_totalWithdrawn += amount;
}
function redeem(uint256 actorSeed, uint256 shares) public useActor(actorSeed) {
uint256 maxRedeem = vault.maxRedeem(currentActor);
if (maxRedeem == 0) return;
shares = bound(shares, 1, maxRedeem);
vault.redeem(shares, currentActor, currentActor);
ghost_totalWithdrawn += vault.previewRedeem(shares);
}
}
See the difference? Every call to deposit will actually succeed because we bound the amount to something the actor can afford. Every withdraw checks the max first. The fuzzer spends its budget on interesting state transitions instead of reverts.
The bound() helper
Foundry's bound() function is your best friend. It takes a random value and maps it into a range:
// bound(value, min, max) -> value in [min, max]
uint256 amount = bound(rawAmount, 1, 1_000_000e18);
A few tips:
- Don't bound to zero. Most protocols treat zero amounts as no-ops or reverts. Start at 1.
- Use realistic ranges. Bounding to
type(uint256).maxis technically valid but wastes cycles on unrealistic scenarios. - For percentages or basis points, bound to the actual range:
bound(bps, 1, 10_000).
Setting up the test contract
Your main test contract points Foundry at the handler:
contract VaultInvariantTest is Test {
Vault vault;
MockERC20 token;
VaultHandler handler;
function setUp() public {
token = new MockERC20("Token", "TKN", 18);
vault = new Vault(token);
handler = new VaultHandler(vault, token);
// Tell Foundry to only call the handler
targetContract(address(handler));
}
function invariant_solvency() public {
assertGe(
token.balanceOf(address(vault)),
vault.totalAssets()
);
}
function invariant_ghostTracking() public {
assertEq(
handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn(),
vault.totalAssets()
);
}
function invariant_sharesConsistency() public {
uint256 totalShares;
for (uint256 i = 0; i < handler.actorsCount(); i++) {
totalShares += vault.balanceOf(handler.actors(i));
}
assertEq(totalShares, vault.totalSupply());
}
}
The key line is targetContract(address(handler)). Without it, Foundry calls random functions on every contract it can see — including the token, which would mess up your invariants.
excludeContract and excludeSelector
Sometimes you need fine-grained control over what the fuzzer touches:
function setUp() public {
// ... deploy everything ...
// Only fuzz through the handler
targetContract(address(handler));
// Don't let the fuzzer call these directly
excludeContract(address(vault));
excludeContract(address(token));
// Exclude specific functions from the handler
bytes4[] memory selectors = new bytes4[](1);
selectors[0] = handler.emergencyWithdraw.selector;
excludeSelector(FuzzSelector({
addr: address(handler),
selectors: selectors
}));
}
Use excludeSelector when your handler has admin functions that would create unrealistic scenarios. If the fuzzer can call pause() at any time, it'll spend half its budget in the paused state and miss bugs in normal operation.
fail_on_revert, the silent bug killer
Here's a config option that changes everything. In your foundry.toml:
[invariant]
runs = 256
depth = 100
fail_on_revert = false # default
With fail_on_revert = false (the default), the fuzzer swallows reverts and moves on. This is fine when you're using handlers that already bound inputs. But sometimes you want to know about reverts.
Set fail_on_revert = true when:
- Your handlers are well-crafted and reverts indicate real bugs
- You're testing that certain operations should never revert under valid inputs
- You want to catch "function reverts when it shouldn't" bugs
Keep it false when:
- You're still iterating on handlers
- You don't have good input bounding yet
- Your protocol has functions that legitimately revert in many states
I usually start with false, get my handlers solid, then flip to true for a final campaign.
Actor management patterns
The modifier pattern I showed earlier works great, but there's a more advanced version for protocols with role-based access:
contract AdvancedHandler is Test {
mapping(uint8 => address[]) actorsByRole;
uint8 constant ROLE_DEPOSITOR = 0;
uint8 constant ROLE_ADMIN = 1;
uint8 constant ROLE_LIQUIDATOR = 2;
function _getActorByRole(uint8 role, uint256 seed) internal view returns (address) {
address[] storage pool = actorsByRole[role];
return pool[bound(seed, 0, pool.length - 1)];
}
function adminSetFee(uint256 actorSeed, uint256 newFee) public {
address admin = _getActorByRole(ROLE_ADMIN, actorSeed);
newFee = bound(newFee, 0, 1000); // max 10%
vm.prank(admin);
vault.setFee(newFee);
}
function userDeposit(uint256 actorSeed, uint256 amount) public {
address user = _getActorByRole(ROLE_DEPOSITOR, actorSeed);
// ... deposit logic ...
}
}
This way, admin functions are only called by admin addresses, and user functions by user addresses. The fuzzer still controls which admin and what parameters — you just prevent impossible scenarios like a random address calling an onlyOwner function.
Ghost variables: your parallel accounting system
Ghosts track what the state should be. Here's a more complete pattern:
// Track every deposit and withdrawal per user
mapping(address => uint256) public ghost_userDeposited;
mapping(address => uint256) public ghost_userWithdrawn;
uint256 public ghost_yieldAccrued;
// Call counter -- useful for debugging
uint256 public ghost_depositCalls;
uint256 public ghost_withdrawCalls;
function deposit(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
// ... bounded deposit ...
ghost_userDeposited[currentActor] += amount;
ghost_depositCalls++;
}
Then your invariants can check:
function invariant_accounting() public {
uint256 expectedBalance = handler.ghost_totalDeposited()
- handler.ghost_totalWithdrawn()
+ handler.ghost_yieldAccrued();
// Allow 1 wei tolerance for rounding
assertApproxEqAbs(
token.balanceOf(address(vault)),
expectedBalance,
handler.ghost_depositCalls() // at most 1 wei per deposit
);
}
That tolerance calculation is important. Rounding errors accumulate. If you've had 50 deposits, each might round down by 1 wei, so you allow 50 wei of drift. This catches real accounting bugs while ignoring expected rounding.
Running campaigns with different depths
Not all invariant campaigns should look the same. Short runs catch shallow bugs fast. Long runs find deep state-dependent issues.
# foundry.toml - quick check (CI)
[profile.ci.invariant]
runs = 64
depth = 50
# foundry.toml - standard (development)
[profile.default.invariant]
runs = 256
depth = 100
# foundry.toml - deep (pre-audit)
[profile.deep.invariant]
runs = 1024
depth = 500
Run them with FOUNDRY_PROFILE=deep forge test. I always run the deep profile before shipping anything to production.
Foundry vs Echidna vs Medusa
Here's the honest comparison. I use all three, and they each have strengths.
Foundry's invariant testing:
- You're already using Foundry, so zero extra setup
- Great Solidity-native DX, handlers are just contracts
bound()andvm.prank()make actor patterns clean- Decent for quick checks and CI integration
- Limited in call sequence depth compared to dedicated fuzzers
- Coverage-guided, it learns which inputs explore new code paths
- Better at finding deep bugs that require specific call sequences
- Config-driven, less Solidity boilerplate
- Slower per run but smarter about exploration
- Also coverage-guided, built on Go
- Faster than Echidna for large contracts
- Parallel execution out of the box
- Growing feature set
My recommendation: start with Foundry because it's the easiest to set up. Once your properties are solid, run the same logic through Echidna or Medusa for deeper exploration. The Chimera framework makes this switch nearly painless, same properties, different runners. Check out the full comparison for details.
Debugging failing invariants
When an invariant fails, Foundry shows you the call sequence. But sequences can be long and hard to parse. A few debugging tricks:
- Add event logging to handlers. Emit events in every handler function with the actor and parameters. Then check the trace.
- Use ghost counters. If you know the invariant fails after
ghost_depositCalls == 3, you can narrow down the sequence. - Write regression tests. Once you've found a failing sequence, write it as a regular
test_function so it never regresses. - Shrink manually. If the sequence is 50 calls, try removing calls from the middle and see if it still fails. Most bugs need 3-5 specific calls.
Where to go from here
If you want to write properties that work across Foundry, Echidna, and Medusa, look into the Chimera framework. If you want to see how invariant testing fits into a broader security strategy, the comparison between Foundry, Echidna, and Medusa covers the trade-offs.
And if you're working on a DeFi protocol and want help setting up a proper invariant suite, we've done this for dozens of teams.
Get a Security Review
deivitto builds security tooling and audits DeFi protocols at Recon. He's run more invariant campaigns than he can count and still finds new bugs every week.
Related Posts
Mutation testing for smart contracts: measure your test suite quality
Your tests pass. But are they actually good? Mutation testing injects faults into your code and chec...
5 Properties Every Smart Contract Auditor Forgets to Test
After 40+ DeFi audits, the same five invariant gaps come up every time. Not the obvious ones — accou...