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— specificallyforge test, its fuzzer, and its invariant runneranvil— not needed here; invariant tests run entirely insideforge
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:
boundsqueezes a fuzzed number into a range. Without it, most runs would use an absurdamountand fail for reasons that teach you nothing.vm.dealsets a balance rather than adding to it. That is why the handler passesaddress(this).balance + value, instead of quietly destroying whatever it withdrew earlier.sendPlainEthuses a low-level call and inspects the result instead of reverting. That way the same handler works both before and afterreceive()exists.targetContractrestricts the fuzzer to the handler. Without it, Foundry targets every contract created insetUp().totalTippedandtotalWithdrawnare 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
- Invariant testing — the official reference for handlers, targets, and configuration
- Fuzz testing — the property-based layer invariant tests are built on
forge testreference — cheatcodes includingbound,vm.deal, andvm.prank- forge-std — the source of
Test,bound, and thevmcheatcode interface - EIP-6780 — why
selfdestructno longer deletes contracts, though it still force-feeds ETH as Section 4 relies on - Adding a Tip Jar to a Webpage — this tutorial’s sibling: ethers.js, MetaMask, and a browser frontend
- Ethereum from the Command Line — the first tutorial in this series
- Writing a Smart Contract with Foundry — the second, which builds the contract tested here
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.