Foundry invariant testing · patterns we use on every audit
Eight invariant patterns worth stealing · we run these on every audit and they keep finding real bugs.
Eight invariant patterns worth stealing · we run these on every audit and they keep finding real bugs.
If unit tests check specific behaviours, invariant tests check general promises. The right invariant catches classes of bugs at once: reentrancy, rounding, access drift, state-machine violations. Foundry makes writing them painless; here are the eight patterns we apply on every audit.
The classic: sum of balances equals total supply. Phrase as: no function can break this invariant. If fuzzing finds a path that does, you have a bug worth hours of fixing.
function invariant_totalSupplyEqualsSumOfBalances() public view {
uint256 sum;
for (uint256 i; i < actors.length; ++i) {
sum += token.balanceOf(actors[i]);
}
assertEq(sum, token.totalSupply(), "conservation broken");
}Nonces, accrued interest, vesting timestamps · things that must only increase. A fuzz run that finds them decreasing is finding a state-machine regression.
No variable should grow unbounded. `tokenIds` array length can't exceed `maxSupply`. `activeLoans` set size bounded by `maxConcurrent`. If invariants around upper bounds break, you have a gas-griefing DoS vector.
If `msg.sender != owner`, admin-only state must stay unchanged. Run the handler with a random non-owner; assert all owner-controlled state is identical pre- and post-call.
function invariant_ownerStateStable() public {
address pre_owner = contract_.owner();
uint256 pre_fee = contract_.feeBps();
_callRandomFunctionAsNonOwner();
assertEq(contract_.owner(), pre_owner);
assertEq(contract_.feeBps(), pre_fee);
}Instead of letting the fuzzer call anything, route all calls through a Handler contract that validates + tracks. Handlers let you emulate realistic user flows (deposit → wait → withdraw) instead of nonsense call graphs.
Maintain a mirror of on-chain state in the Handler. After every call, compare contract state to ghost · any divergence is a bug. Especially powerful for accounting contracts.
For every public function, assert the delta follows the intended semantics: `balanceAfter == balanceBefore - amount` after `withdraw(amount)`, with no side effects on other users' balances.
The bug that unit tests never find: function A + function B run in sequence violate an invariant the functions individually respect. Randomised fuzzing over the call graph catches these.
Run invariants with `forge test --match-contract Invariant --fuzz-runs 200000`. 3 hours on CI is normal for a 2000-line codebase and finds bugs years of manual review miss.

Founder, DField Solutions
I've shipped production products from fintech to creator-tooling · for startups and enterprises, from Budapest to San Francisco.
Let's talk about your project. 30 minutes, no strings.