Skip to content

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).
ShareXLinkedIn#
Dezső Mező

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

Would rather build together?

Let's talk about your project. 30 minutes, no strings.

Let's talk