2026-03-27·10 min read

How to write your first invariant test

By Nican0r · Lead Invariants Engineer

How to write your first invariant test

Unit tests check the cases you think of. Invariant tests check everything you don't. They define what must always be true and let a fuzzer throw thousands of random inputs at your contract to prove it.

This tutorial walks you through writing three invariant properties for a simple ERC-20 vault, then running them with both Foundry and Echidna. You'll have a working test suite in about 30 minutes.

What you'll build

You're going to take a minimal deposit/withdraw vault contract and write three properties that must always hold:

  1. Total assets in the vault always equal the sum of all user deposits minus withdrawals
  2. No single user's balance ever exceeds total vault assets
  3. A user can't withdraw more than they deposited

Then you'll run those properties with Foundry for fast local feedback and with Echidna for deeper stateful fuzzing. Same properties, two fuzzers, zero rewrites.

Prerequisites

You need Foundry installed, basic Solidity knowledge, and about 30 minutes. If you haven't used Foundry before, run curl -L https://foundry.paradigm.xyz | bash && foundryup to get started.

For the Echidna section, install it via pip3 install crytic-compile && brew install echidna (or see the Echidna docs).

The target contract

Here's a deliberately simple vault. It accepts an ERC-20 token, tracks balances per user, and lets users deposit and withdraw.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract SimpleVault {
    IERC20 public token;
    mapping(address => uint256) public balances;
    uint256 public totalDeposited;

    constructor(address _token) { token = IERC20(_token); }

    function deposit(uint256 amount) external {
        token.transferFrom(msg.sender, address(this), amount);
        balances[msg.sender] += amount;
        totalDeposited += amount;
    }

    function withdraw(uint256 amount) external {
        balances[msg.sender] -= amount;
        totalDeposited -= amount;
        token.transfer(msg.sender, amount);
    }
}

This is 20 lines of logic. Even here, there's room for bugs. Can a user withdraw more than they deposited if balances underflows? The invariants will tell us.

Thinking in properties

The mental shift from unit testing to invariant testing is simple. Stop asking "does this specific call return the right value?" and start asking "what must always be true, no matter what sequence of calls happens?"

For this vault, three things must always hold:

  • Solvency: the vault's token balance is always at least totalDeposited. If it's less, funds have leaked.
  • Balance cap: no single user's recorded balance exceeds totalDeposited. If it does, the accounting is broken.
  • Withdraw bound: a withdraw call with an amount larger than the caller's balance should revert. If it doesn't, users can steal funds.

These aren't test cases. They're contracts about reality. The fuzzer's job is to try to break them.

Writing the properties

Create a test file at test/InvariantTest.sol. The three properties go here alongside handler functions that guide the fuzzer.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol";
import {SimpleVault} from "../src/SimpleVault.sol";
import {MockERC20} from "forge-std/mocks/MockERC20.sol";

contract VaultHandler is Test {
    SimpleVault public vault;
    MockERC20 public token;

    constructor(SimpleVault _vault, MockERC20 _token) {
        vault = _vault;
        token = _token;
    }

    function handler_deposit(uint256 amount) external {
        amount = bound(amount, 1, 1_000e18);
        token.mint(address(this), amount);
        token.approve(address(vault), amount);
        vault.deposit(amount);
    }

    function handler_withdraw(uint256 amount) external {
        uint256 bal = vault.balances(address(this));
        if (bal == 0) return;
        amount = bound(amount, 1, bal);
        vault.withdraw(amount);
    }
}

The handler functions use bound() to constrain random inputs into valid ranges. This keeps the fuzzer focused on meaningful sequences rather than wasting time on trivially reverting calls.

Now the invariant assertions:

contract InvariantTest is Test {
    SimpleVault public vault;
    MockERC20 public token;
    VaultHandler public handler;

    function setUp() public {
        token = new MockERC20();
        vault = new SimpleVault(address(token));
        handler = new VaultHandler(vault, token);
        targetContract(address(handler));
    }

    function invariant_solvency() public view {
        uint256 vaultBalance = token.balanceOf(address(vault));
        assertGe(vaultBalance, vault.totalDeposited());
    }

    function invariant_balanceCap() public view {
        uint256 handlerBal = vault.balances(address(handler));
        assertLe(handlerBal, vault.totalDeposited());
    }
}

Each invariant_ function runs after every fuzzer call. If any assertion fails, the fuzzer stops and reports the exact call sequence that broke it.

Running with Foundry

Run your invariants from the project root:

forge test --match-contract InvariantTest -vvv

Foundry will randomly call your handler functions and check every invariant_ function after each call. A passing run looks like this:

[PASS] invariant_solvency() (runs: 256, calls: 3840)
[PASS] invariant_balanceCap() (runs: 256, calls: 3840)

The runs count is how many random sequences Foundry generated. The calls count is the total number of handler invocations across all sequences. More is better. Increase depth in foundry.toml:

[invariant]
runs = 512
depth = 32

Running with Echidna

Echidna uses a different approach: it needs a single contract with all handlers and properties combined, and properties return bool instead of using assert. Here's a minimal config:

# echidna.yaml
testMode: property
testLimit: 50000
seqLen: 30
corpusDir: "corpus"

And the Echidna-compatible test contract:

contract EchidnaTest {
    SimpleVault vault;
    MockERC20 token;

    constructor() {
        token = new MockERC20();
        vault = new SimpleVault(address(token));
    }

    function handler_deposit(uint256 amount) public {
        amount = amount % 1_000e18 + 1;
        token.mint(address(this), amount);
        token.approve(address(vault), amount);
        vault.deposit(amount);
    }

    function echidna_solvency() public view returns (bool) {
        return token.balanceOf(address(vault)) >= vault.totalDeposited();
    }
}

Run it:

echidna test/EchidnaTest.sol --contract EchidnaTest --config echidna.yaml

Echidna outputs passed or failed for each property. It's slower than Foundry per run but explores deeper state spaces over longer campaigns. For a full comparison, see Echidna vs Medusa.

What to do when a test fails

When a fuzzer breaks a property, it gives you a counterexample: the exact sequence of calls and arguments that triggered the failure.

invariant_solvency() failed after:
  handler_deposit(500000000000000000)
  handler_withdraw(500000000000000001)

This tells you a user deposited 0.5 tokens and then withdrew 0.5 + 1 wei. That means withdraw didn't check the balance properly. To fix this, add a require statement:

function withdraw(uint256 amount) external {
    require(amount <= balances[msg.sender], "exceeds balance");
    balances[msg.sender] -= amount;
    totalDeposited -= amount;
    token.transfer(msg.sender, amount);
}

The workflow is: read the counterexample, reproduce it as a unit test, understand the root cause, fix the code, then rerun the fuzzer to confirm. Every broken invariant is a bug you found before an attacker did.

Related


Ready to go beyond the basics? Request an audit with Recon — we'll build a full invariant test suite for your protocol and run it at scale with coverage-guided fuzzing.

Related Posts

Related Glossary Terms

Need help securing your protocol?