DField SolutionsMérnöki stúdió · Budapest
Loading · Töltődik
Skip to content
Back to blog
·12 min read
Blockchain··12 min read

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.

Last verified
Listen
Dezső Mező
Founder, DField Solutions
ShareXLinkedIn#
Foundry invariant testing · patterns we use on every audit

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'm a full-stack engineer and I build across the whole stack myself · AI agents, web and mobile apps, blockchain, backends, security, right down to the OS layer. If it's software, I've probably built it and broken it.

Keep reading
RELATED PROJECTS
Let's talk

Would rather build together?

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