---
title: "Foundry invariant testing · patterns we use on every audit"
description: "Invariant tests catch bugs property-based + unit tests can't. The 8 invariant patterns we apply to every Solidity codebase, with Foundry examples."
date: 2026-04-22
updated: 2026-04-22
author: "Dezső Mező"
tags: "Blockchain, Solidity, Foundry, Audit, Testing, Invariant"
slug: foundry-invariant-testing-patterns-2026
canonical: https://dfieldsolutions.com/blog/foundry-invariant-testing-patterns-2026
---

# 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.
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.

```solidity
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.

```solidity
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.

> **TIP:** 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).

---

Source: https://dfieldsolutions.com/blog/foundry-invariant-testing-patterns-2026
Author: Dezső Mező · Founder, DField Solutions
Site: https://dfieldsolutions.com
