Unit tests check that a function does what you expected. Invariant tests check something harder: that a property of your contract holds no matter what anybody does to it. This tutorial writes both kinds against a TipJar contract, watches the invariant catch a bug every unit test missed, and then shows why the obvious invariant is still wrong.

It follows on from Writing a Smart Contract with Foundry, and picks up exactly where that one ends. Its sibling, Adding a Tip Jar to a Webpage, takes the same contract in the other direction and puts a browser frontend on it. Neither depends on the other.

What You Will Use

  • forge — specifically forge test, its fuzzer, and its invariant runner
  • anvil — not needed here; invariant tests run entirely inside forge

Prerequisites

You need the TipJar project from the previous tutorial, with the fixed receive() from its Section 20. If you have it, skip ahead.

If not, get the container and toolchain up:

host : ~ : main

mkdir -p ~/tip-jar-work

podman run -dit \
    --name foundry \
    --volume ~/tip-jar-work:/work:Z \
    --workdir /work \
    ubuntu sleep infinity

podman exec -it foundry bash

container : /work : main

apt-get update
apt-get install -y curl git ca-certificates

curl -L https://foundry.paradigm.xyz | bash
source ~/.bashrc
foundryup

forge init tip-jar --empty
cd tip-jar

Then create src/TipJar.sol with the finished contract — the version that routes both entry points through one internal function:

container : /work/tip-jar : main

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

contract TipJar {
    address public immutable owner;

    mapping(address => uint256) public tipsByAddress;
    uint256 public totalTips;

    event TipReceived(address indexed sender, uint256 amount, string message);
    event Withdrawn(address indexed owner, uint256 amount);

    constructor() {
        owner = msg.sender;
    }

    function tip(string calldata message) external payable {
        _tip(message);
    }

    receive() external payable {
        _tip("");
    }

    function _tip(string memory message) private {
        require(msg.value > 0, "Tip must be greater than zero");

        tipsByAddress[msg.sender] += msg.value;
        totalTips += msg.value;

        emit TipReceived(msg.sender, msg.value, message);
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function withdraw() external {
        require(msg.sender == owner, "Only owner can withdraw");

        uint256 amount = address(this).balance;
        require(amount > 0, "Nothing to withdraw");

        totalTips = 0;

        (bool success, ) = payable(owner).call{value: amount}("");
        require(success, "Withdrawal failed");

        emit Withdrawn(owner, amount);
    }
}

Check it builds:

container : /work/tip-jar : main

forge build

Nothing in this tutorial needs Anvil. forge test runs its own EVM in process, which is why invariant tests can execute thousands of transactions in seconds.

Where Each Command Runs

Every command block is preceded by a line saying where it belongs:

`container : /work/tip-jar : main`
     │            │            │
     │            │            └── which window
     │            └── the working directory
     └── host machine, or inside the container

Only one shell is needed for this tutorial.


1. Why Unit Tests Missed It

The bug in Section 20 of the previous tutorial is the interesting kind: the code was not wrong in any single step, it just let two pieces of state drift apart. Adding a bare receive() let ETH arrive without totalTips or tipsByAddress moving, so the contract’s balance and its own bookkeeping quietly disagreed.

Every unit test from Section 19 still passed, because every one of them calls tip(). Nobody thought to write a test for the path they had just created.

Invariant tests attack that blind spot from the other direction. Instead of “given this input, expect that output”, you state a property that must hold no matter what happens, and Foundry generates long random sequences of calls trying to break it.

Here the property is the one the naive receive() violated: the contract’s recorded tips should equal the ETH it holds.


2. A Handler

Point the fuzzer at a handler rather than at TipJar directly. A handler is a contract that stands in for the outside world: it holds the ETH, keeps the random inputs in a sensible range, and decides which paths are worth exercising.

Create test/TipJarInvariant.t.sol:

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

import {Test} from "forge-std/Test.sol";
import {TipJar} from "../src/TipJar.sol";

contract TipJarHandler is Test {
    TipJar public immutable tipJar;

    uint256 public totalTipped;
    uint256 public totalWithdrawn;

    constructor() {
        // The handler deploys the jar, so the handler is the owner.
        tipJar = new TipJar();
    }

    // Required so the handler can receive its own withdrawals.
    receive() external payable {}

    function tip(uint96 amount, string calldata message) external {
        uint256 value = bound(amount, 1, 10 ether);
        vm.deal(address(this), address(this).balance + value);

        tipJar.tip{value: value}(message);
        totalTipped += value;
    }

    function sendPlainEth(uint96 amount) external {
        uint256 value = bound(amount, 1, 10 ether);
        vm.deal(address(this), address(this).balance + value);

        (bool ok, ) = address(tipJar).call{value: value}("");
        if (ok) {
            totalTipped += value;
        }
    }

    function withdraw() external {
        if (address(tipJar).balance == 0) {
            return;
        }

        totalWithdrawn += address(tipJar).balance;
        tipJar.withdraw();
    }
}

contract TipJarInvariantTest is Test {
    TipJarHandler private handler;
    TipJar private tipJar;

    function setUp() public {
        handler = new TipJarHandler();
        tipJar = handler.tipJar();

        targetContract(address(handler));
    }

    function invariant_accountingMatchesBalance() public view {
        assertEq(tipJar.totalTips(), address(tipJar).balance);
    }

    function invariant_noEthCreatedOrDestroyed() public view {
        assertEq(
            address(tipJar).balance,
            handler.totalTipped() - handler.totalWithdrawn()
        );
    }
}

Notes on the mechanics:

  • bound squeezes a fuzzed number into a range. Without it, most runs would use an absurd amount and fail for reasons that teach you nothing.
  • vm.deal sets a balance rather than adding to it. That is why the handler passes address(this).balance + value, instead of quietly destroying whatever it withdrew earlier.
  • sendPlainEth uses a low-level call and inspects the result instead of reverting. That way the same handler works both before and after receive() exists.
  • targetContract restricts the fuzzer to the handler. Without it, Foundry targets every contract created in setUp().
  • totalTipped and totalWithdrawn are ghost variables: bookkeeping that lives only in the test, so the invariant has something independent to compare the contract against.

Invariant tests run as part of the normal test command:

container : /work/tip-jar : main

forge test --match-contract TipJarInvariantTest -vv

How hard it searches is configured in foundry.toml, in the project root:

[invariant]
runs = 256
depth = 50
fail_on_revert = true

runs is how many random sequences to try, depth is how many calls per sequence. fail_on_revert = true is worth turning on while writing a handler: it tells you when handler calls are reverting and therefore testing nothing.


3. Watching It Catch the Bug

Both invariants pass against the fixed contract. To see the point of them, put the broken version back:

receive() external payable {}

Then rerun:

container : /work/tip-jar : main

forge test --match-contract TipJarInvariantTest -vv

invariant_accountingMatchesBalance fails, and Foundry prints the call sequence that broke it — a sendPlainEth that moved the balance without moving totalTips. Nobody had to think of that case.

Note that invariant_noEthCreatedOrDestroyed still passes, because no ETH went missing. The two invariants are checking genuinely different things: one says the contract’s story about itself is consistent, the other says nothing leaked.


4. Why assertEq Is Still Too Strong

There is a catch, and it is a good lesson in its own right. A contract cannot refuse ETH sent by a selfdestruct, and no code of yours runs when it arrives. Add this to the invariant file:

contract ForceFeeder {
    constructor(address payable target) payable {
        selfdestruct(target);
    }
}

And a unit test that uses it:

function testBalanceCanExceedAccounting() public {
    vm.deal(address(this), 1 ether);

    new ForceFeeder{value: 1 ether}(payable(address(tipJar)));

    assertEq(address(tipJar).balance, 1 ether);
    assertEq(tipJar.totalTips(), 0);
}

The ETH lands, totalTips stays at zero, and there is nothing TipJar can do about it. So totalTips == balance is not a property any contract can actually guarantee. The honest version is one-directional:

function invariant_accountingNeverExceedsBalance() public view {
    assertLe(tipJar.totalTips(), address(tipJar).balance);
}

Tips are always covered by real ETH, but the balance may be larger than the contract knows about. This is also the reason withdraw() sends address(this).balance rather than totalTips: reading the balance sweeps force-fed ETH out too, where trusting the accounting would strand it in the contract forever.

The general rule is worth remembering beyond this contract: a contract’s internal accounting and its actual balance are two different numbers, and code that assumes they are equal is a common source of stuck funds.


5. Where to Go Next

The general rule from Section 4 is worth carrying beyond this contract: a contract’s internal accounting and its actual balance are two different numbers, and code that assumes they are equal is a common source of stuck funds.

Two directions from here.

Fuzz your own properties. Anything you can state as “this should always be true” is a candidate. Balances that should sum to a total, a supply that should never exceed a cap, an access check that should never let a non-owner through.

Read the handler patterns properly. Real invariant suites spend most of their effort on the handler — restricting the fuzzer to sensible inputs, and making sure calls are not silently reverting and therefore testing nothing. fail_on_revert = true is how you find that out.


6. Additional Resources


Summary

unit test        given this input, expect that output
fuzz test        for any input in a range, this should hold
invariant test   after ANY sequence of calls, this should hold

Unit tests check the paths you thought of. Invariant tests are how you find out about the ones you did not — which, as the naive receive() showed, are generally the paths that lose money.