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.
1 · Conservation
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");
}2 · Monotonicity
Nonces, accrued interest, vesting timestamps · things that must only increase. A fuzz run that finds them decreasing is finding a state-machine regression.
3 · Bounded state
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.
4 · Access parity
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);
}5 · Handler-based fuzz
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.
6 · Ghost variables
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.
7 · Pre/post delta
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.
8 · Cross-function coupling
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.
Real findings from invariant runs
- Lending protocol · `repay` with 1 wei off-by-one stranded $12M pre-mainnet (caught by conservation invariant).
- Vault · bid-rigging via donation attack (caught by pre-deploy fuzz, ERC-4626 vault inflation).
- DEX · rounding drift in LP shares after 10k swaps (bounded-state invariant).
- Governance · proposal replay with same nonce across forks (monotonicity invariant).

By
Dezső Mező
Founder, DField Solutions
I've shipped production products from fintech to creator-tooling · for startups and enterprises, from Budapest to San Francisco.
Keep reading
RELATED PROJECTS