Foundry Invariant and Fuzz Testing: Finding Smart Contract Bugs Computers Think Of First
Traditional unit tests check scenarios you think of. Fuzz testing and invariant testing find the scenarios you didn't think of — and in smart contracts, those are often the $10M bugs.
Foundry's fuzzer has become the industry standard for property-based testing of Solidity contracts. This guide covers everything from basic fuzz tests to advanced stateful invariant testing that simulates complex DeFi protocol interactions.
Why Fuzzing Matters for Smart Contracts
Smart contracts are adversarial environments. You don't get to choose who calls your functions or with what parameters. An attacker will try every possible input combination looking for edge cases.
The DAO hack (2016, $60M): a reentrancy bug exploitable via a specific call sequence.
The Euler Finance hack (2023, $197M): a donation+liquidation sequence that violated an accounting invariant.
The Nomad bridge hack (2022, $190M): an initialization bug that anyone could exploit.
In each case, the invariant was: "total funds controlled by the protocol should equal deposits minus withdrawals." Invariant testing would have caught all three.
Setting Up Foundry
# Install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Create project
forge init my-defi-protocol
cd my-defi-protocol
# Install dependencies
forge install OpenZeppelin/openzeppelin-contracts
forge install foundry-rs/forge-std# foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
# Fuzzing configuration
[profile.default.fuzz]
runs = 1000 # Number of random inputs to try per fuzz test
max_test_rejects = 65536
seed = 0 # Reproducible seeds for CI
# Invariant testing configuration
[profile.default.invariant]
runs = 256 # Number of call sequences to try
depth = 128 # Max calls per sequence
fail_on_revert = false # Reverts are OK; we care about invariant violations
[profile.ci.fuzz]
runs = 10000 # More runs in CI for better coverageBasic Fuzz Testing
Let's start with a simple vault contract:
// src/Vault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
IERC20 public token;
mapping(address => uint256) public balances;
uint256 public totalDeposits;
constructor(address _token) {
token = IERC20(_token);
}
function deposit(uint256 amount) external nonReentrant {
require(amount > 0, "Zero amount");
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
totalDeposits += amount;
}
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
totalDeposits -= amount;
token.transfer(msg.sender, amount);
}
}Basic Fuzz Test
// test/Vault.fuzz.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/Vault.sol";
import "./mocks/MockERC20.sol";
contract VaultFuzzTest is Test {
Vault public vault;
MockERC20 public token;
address public alice = makeAddr("alice");
function setUp() public {
token = new MockERC20("Test Token", "TEST", 18);
vault = new Vault(address(token));
}
// Basic fuzz: deposit any amount
function testFuzz_Deposit(uint256 amount) public {
// Bound to reasonable range (avoid overflow, zero)
amount = bound(amount, 1, type(uint128).max);
// Setup
token.mint(alice, amount);
vm.startPrank(alice);
token.approve(address(vault), amount);
// Action
vault.deposit(amount);
// Assert
assertEq(vault.balances(alice), amount);
assertEq(vault.totalDeposits(), amount);
assertEq(token.balanceOf(address(vault)), amount);
vm.stopPrank();
}
// Fuzz: deposit then withdraw
function testFuzz_DepositWithdraw(uint256 depositAmount, uint256 withdrawAmount) public {
depositAmount = bound(depositAmount, 1, type(uint128).max);
withdrawAmount = bound(withdrawAmount, 1, depositAmount);
token.mint(alice, depositAmount);
vm.startPrank(alice);
token.approve(address(vault), depositAmount);
vault.deposit(depositAmount);
vault.withdraw(withdrawAmount);
assertEq(vault.balances(alice), depositAmount - withdrawAmount);
assertEq(vault.totalDeposits(), depositAmount - withdrawAmount);
assertEq(
token.balanceOf(address(vault)),
depositAmount - withdrawAmount
);
vm.stopPrank();
}
// Fuzz: multiple users
function testFuzz_MultiUserDeposit(
address user1,
address user2,
uint256 amount1,
uint256 amount2
) public {
// Sanitize inputs
vm.assume(user1 != address(0) && user2 != address(0));
vm.assume(user1 != user2);
vm.assume(user1 != address(vault) && user2 != address(vault));
amount1 = bound(amount1, 1, type(uint64).max);
amount2 = bound(amount2, 1, type(uint64).max);
// User 1 deposits
token.mint(user1, amount1);
vm.startPrank(user1);
token.approve(address(vault), amount1);
vault.deposit(amount1);
vm.stopPrank();
// User 2 deposits
token.mint(user2, amount2);
vm.startPrank(user2);
token.approve(address(vault), amount2);
vault.deposit(amount2);
vm.stopPrank();
// Total deposits should equal sum
assertEq(vault.totalDeposits(), amount1 + amount2);
assertEq(token.balanceOf(address(vault)), amount1 + amount2);
}
}Stateful Invariant Testing
Invariant tests are more powerful — they define properties that must hold across any sequence of operations.
// test/Vault.invariant.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/Vault.sol";
import "./mocks/MockERC20.sol";
// Handler: wraps contract calls to guide the fuzzer
contract VaultHandler is Test {
Vault public vault;
MockERC20 public token;
// Ghost variables: track state outside the contract
uint256 public ghost_totalDeposited;
uint256 public ghost_totalWithdrawn;
address[] public actors;
mapping(address => uint256) public actorBalances;
constructor(Vault _vault, MockERC20 _token) {
vault = _vault;
token = _token;
// Create test actors
actors.push(makeAddr("actor1"));
actors.push(makeAddr("actor2"));
actors.push(makeAddr("actor3"));
}
function _randomActor(uint256 seed) internal view returns (address) {
return actors[seed % actors.length];
}
function deposit(uint256 actorSeed, uint256 amount) external {
address actor = _randomActor(actorSeed);
amount = bound(amount, 1, 1_000_000e18);
token.mint(actor, amount);
vm.startPrank(actor);
token.approve(address(vault), amount);
vault.deposit(amount);
vm.stopPrank();
// Update ghost state
ghost_totalDeposited += amount;
actorBalances[actor] += amount;
}
function withdraw(uint256 actorSeed, uint256 amount) external {
address actor = _randomActor(actorSeed);
uint256 balance = vault.balances(actor);
if (balance == 0) return; // Skip if nothing to withdraw
amount = bound(amount, 1, balance);
vm.prank(actor);
vault.withdraw(amount);
ghost_totalWithdrawn += amount;
actorBalances[actor] -= amount;
}
}
contract VaultInvariantTest is Test {
Vault public vault;
MockERC20 public token;
VaultHandler public handler;
function setUp() public {
token = new MockERC20("Test", "TEST", 18);
vault = new Vault(address(token));
handler = new VaultHandler(vault, token);
// Tell Foundry to only call the handler
targetContract(address(handler));
}
// Invariant 1: Token balance = deposits - withdrawals
function invariant_TokenBalanceMatchesAccounting() public {
assertEq(
token.balanceOf(address(vault)),
handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn(),
"Token balance doesn't match accounting"
);
}
// Invariant 2: totalDeposits matches the sum of individual balances
function invariant_TotalDepositsMatchIndividualBalances() public {
address[] memory actors = handler.getActors();
uint256 sum;
for (uint i = 0; i < actors.length; i++) {
sum += vault.balances(actors[i]);
}
assertEq(vault.totalDeposits(), sum, "totalDeposits != sum of balances");
}
// Invariant 3: No user can have more balance than total deposits
function invariant_NoUserExceedsTotalDeposits() public {
address[] memory actors = handler.getActors();
for (uint i = 0; i < actors.length; i++) {
assertLe(
vault.balances(actors[i]),
vault.totalDeposits(),
"User balance exceeds total deposits"
);
}
}
// Invariant 4: Contract token balance should always equal totalDeposits
function invariant_ContractBalanceEqualsTotalDeposits() public {
assertEq(
token.balanceOf(address(vault)),
vault.totalDeposits(),
"Contract balance != totalDeposits"
);
}
}Finding Real Bugs with Invariant Testing
Let's look at a buggy contract and how invariant testing finds the bug:
// src/BuggyVault.sol — intentionally broken
contract BuggyVault {
mapping(address => uint256) public balances;
uint256 public totalDeposits;
// BUG: fee calculation has rounding error that drains contract
function depositWithFee(uint256 amount) external {
uint256 fee = amount / 100; // 1% fee
uint256 credited = amount - fee;
balances[msg.sender] += credited;
totalDeposits += credited; // BUG: should be += amount (we received amount)
// Result: totalDeposits < actual token balance, allowing double-counting
}
}The invariant test will catch this because totalDeposits will be consistently less than the actual token balance.
Advanced: Fork Testing with Invariants
Test your protocol against real mainnet state:
// test/Fork.invariant.t.sol
contract DeFiProtocolInvariantTest is Test {
// Fork from mainnet
function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_000_000);
// Use real USDC, DAI, WETH
usdc = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
dai = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
// Deploy your protocol on top of real state
protocol = new MyProtocol(address(usdc), address(dai));
}
function invariant_ProtocolSolvency() public {
// Total value locked should always cover all outstanding shares
uint256 tvl = protocol.totalValueLocked();
uint256 totalShares = protocol.totalShares();
uint256 sharePrice = protocol.sharePrice();
assertGe(tvl, (totalShares * sharePrice) / 1e18, "Protocol insolvent");
}
}Running the Tests
# Run all tests
forge test
# Run only fuzz tests (verbose)
forge test --match-test "testFuzz_" -vvv
# Run invariant tests
forge test --match-test "invariant_" -vvv
# Run with more fuzz iterations
forge test --fuzz-runs 10000
# When an invariant fails, replay the sequence
forge test --match-test "invariant_" -vvvv
# Save corpus for regression testing
forge test --fuzz-seed 12345
# Generate gas report alongside testing
forge test --gas-reportIntegrating with HelpMeTest for CI Monitoring
For deployed contracts, static testing isn't enough — you need runtime monitoring. HelpMeTest can continuously verify on-chain invariants:
Health Check: DeFi Protocol Solvency Monitor
Schedule: Every 5 minutes
Steps:
1. Call totalValueLocked() on deployed contract
2. Call totalShares() and sharePrice()
3. Verify TVL >= (totalShares * sharePrice) / 1e18
4. Verify no unusual balance changes > 10% in last block
5. Alert security team immediately if any invariant violatedOn-chain monitoring is the production equivalent of invariant testing — it runs continuously against real state, not simulated state. The combination of Foundry invariant tests (pre-deployment) and HelpMeTest health checks (post-deployment) gives you defense in depth.
Key Takeaways
- Use
bound()notassume()for numeric inputs —assume()causes test rejection when conditions aren't met,bound()keeps values in range - Ghost variables track state that's hard to read from the contract directly
- Handler contracts give the fuzzer a curated set of valid operations
- Stateful invariants find bugs that emerge from interaction sequences, not just single calls
- Fork tests validate against real mainnet state, catching integration issues
The property "this should always be true no matter what" is the most powerful statement you can make about a contract. Write it as an invariant test, let Foundry try to break it, and sleep better knowing your protocol has been subjected to millions of attack scenarios your auditor didn't think of.