# Invariant Testing with Foundry — From Zero to Production
Unit tests check specific behaviors. Fuzz tests check edge cases. But invariant tests check properties that must always hold, no matter what sequence of actions occurs.
Invariants are the closest thing we have to formal verification in practical smart contract testing. They catch bugs that unit tests miss.
Here is how to write invariant tests with Foundry, from basics to production-grade suites.
Unit, Fuzz, and Invariant Tests
Unit Test
function testDeposit() public {
vault.deposit(100);
assertEq(vault.balanceOf(address(this)), 100);
}
Tests one specific scenario.
Fuzz Test
function testDeposit(uint256 amount) public {
vm.assume(amount > 0 && amount < 1e30);
vault.deposit(amount);
assertEq(vault.balanceOf(address(this)), amount);
}
Tests many random inputs, but still one action per test.
Invariant Test
function invariant_totalSupplyEqualsSumOfBalances() public {
uint256 sum = 0;
for (uint i = 0; i < users.length; i++) {
sum += vault.balanceOf(users[i]);
}
assertEq(vault.totalSupply(), sum);
}
Tests a property that must hold after any sequence of actions.
Foundry generates random call sequences (deposit, withdraw, transfer, etc.) and checks that the invariant still holds.
Writing Your First Invariant Test
Example: ERC20 Token
contract TokenInvariantTest is Test {
Token token;
Handler handler;
function setUp() public {
token = new Token("Test", "TST");
handler = new Handler(token);
targetContract(address(handler));
}
function invariant_totalSupplyEqualsSumOfBalances() public {
assertEq(token.totalSupply(), handler.sumOfBalances());
}
}
contract Handler {
Token token;
address[] public actors;
constructor(Token _token) {
token = _token;
}
function mint(uint256 actorSeed, uint256 amount) public {
address actor = _getActor(actorSeed);
vm.prank(token.owner());
token.mint(actor, amount);
_updateSum(actor);
}
function transfer(uint256 fromSeed, uint256 toSeed, uint256 amount) public {
address from = _getActor(fromSeed);
address to = _getActor(toSeed);
vm.prank(from);
token.transfer(to, amount);
_updateSum(from);
_updateSum(to);
}
function sumOfBalances() public view returns (uint256) {
uint256 sum = 0;
for (uint i = 0; i < actors.length; i++) {
sum += token.balanceOf(actors[i]);
}
return sum;
}
function _getActor(uint256 seed) internal returns (address) {
uint256 index = seed % 10;
if (index >= actors.length) {
address newActor = address(uint160(uint256(keccak256(abi.encode(actors.length)))));
actors.push(newActor);
}
return actors[index];
}
function _updateSum(address actor) internal {
// Ghost variable tracking (optional)
}
}
How It Works
Handlerinvariant_* functionsCommon Invariants
1. Conservation Laws
Total supply equals sum of balances:
function invariant_conservation() public {
assertEq(vault.totalAssets(), vault.totalDeposits() - vault.totalWithdrawals());
}
2. Solvency
Contract can always pay out:
function invariant_solvency() public {
assertGe(vault.totalAssets(), vault.totalLiabilities());
}
3. Monotonicity
Certain values only increase:
function invariant_totalSupplyNeverDecreases() public {
assertGe(token.totalSupply(), handler.lastTotalSupply());
handler.updateLastTotalSupply();
}
4. Access Control
Privileged functions are only callable by authorized users:
function invariant_onlyOwnerCanMint() public {
// Handler ensures only owner calls mint
// Invariant: totalSupply only changes via authorized mints
}
Writing Handlers
Handlers are the key to good invariant tests. They define valid actions on your contract.
Handler Best Practices
vm.assume or modulo to avoid revertsExample: ERC4626 Vault Handler
contract VaultHandler {
Vault vault;
IERC20 asset;
address[] public actors;
mapping(address => uint256) public ghost_deposits;
mapping(address => uint256) public ghost_withdrawals;
constructor(Vault _vault, IERC20 _asset) {
vault = _vault;
asset = _asset;
}
function deposit(uint256 actorSeed, uint256 amount) public {
amount = bound(amount, 0, 1e30);
address actor = _getActor(actorSeed);
asset.mint(actor, amount);
vm.startPrank(actor);
asset.approve(address(vault), amount);
vault.deposit(amount, actor);
vm.stopPrank();
ghost_deposits[actor] += amount;
}
function withdraw(uint256 actorSeed, uint256 amount) public {
address actor = _getActor(actorSeed);
uint256 shares = vault.balanceOf(actor);
if (shares == 0) return;
amount = bound(amount, 0, shares);
vm.prank(actor);
vault.withdraw(amount, actor, actor);
ghost_withdrawals[actor] += amount;
}
function _getActor(uint256 seed) internal returns (address) {
uint256 index = seed % 10;
if (index >= actors.length) {
actors.push(address(uint160(uint256(keccak256(abi.encode(actors.length))))));
}
return actors[index];
}
}
Full Example: ERC4626 Vault
contract VaultInvariantTest is Test {
Vault vault;
MockERC20 asset;
VaultHandler handler;
function setUp() public {
asset = new MockERC20();
vault = new Vault(asset);
handler = new VaultHandler(vault, asset);
targetContract(address(handler));
}
function invariant_solvency() public {
assertGe(asset.balanceOf(address(vault)), vault.totalAssets());
}
function invariant_totalAssetsMatchesDeposits() public {
uint256 totalDeposits = 0;
for (uint i = 0; i < handler.actorsLength(); i++) {
totalDeposits += handler.ghost_deposits(handler.actors(i));
totalDeposits -= handler.ghost_withdrawals(handler.actors(i));
}
assertEq(vault.totalAssets(), totalDeposits);
}
function invariant_sharesAlwaysRedeemable() public {
for (uint i = 0; i < handler.actorsLength(); i++) {
address actor = handler.actors(i);
uint256 shares = vault.balanceOf(actor);
uint256 assets = vault.convertToAssets(shares);
assertGe(vault.totalAssets(), assets);
}
}
function invariant_noFreeShares() public {
for (uint i = 0; i < handler.actorsLength(); i++) {
address actor = handler.actors(i);
if (handler.ghost_deposits(actor) == 0) {
assertEq(vault.balanceOf(actor), 0);
}
}
}
}
Configuring Runs
Set the number of runs and depth in foundry.toml:
[invariant]
runs = 256 # Number of sequences to test
depth = 50 # Max actions per sequence
fail_on_revert = false
Start with low runs (16-32) for fast iteration, then increase to 256+ for CI.
Shrinking
When an invariant fails, Foundry shrinks the failing sequence to the minimal reproduction.
Example output:
Failing sequence:
1. deposit(actorSeed: 5, amount: 1000)
2. withdraw(actorSeed: 5, amount: 1500)
Invariant violation: solvency
Expected: >= 1000
Actual: 500
This makes debugging much easier.
Ghost Variables
Ghost variables track expected state outside the contract:
mapping(address => uint256) public ghost_deposits;
function deposit(uint256 actorSeed, uint256 amount) public {
// ... actual deposit
ghost_deposits[actor] += amount;
}
function invariant_ghostMatchesActual() public {
for (uint i = 0; i < actors.length; i++) {
assertEq(vault.balanceOf(actors[i]), ghost_deposits[actors[i]]);
}
}
Summary
Invariant testing is the most powerful testing technique for smart contracts:
Common invariants:
- Conservation: total supply = sum of balances
- Solvency: assets >= liabilities
- Monotonicity: certain values only increase
- Access control: only authorized actions
Invariant tests catch bugs that unit tests miss. They are the closest thing to formal verification that is practical for everyday development.
Start small, add invariants as you go, and you will catch bugs before they hit production.