← Back to Blog

Foundry Invariant Fuzzing for Smart Contract Security: A Practical Guide

2026-04-17 foundry fuzzing solidity security invariant-testing testing defi 2026

Foundry's fuzzing engine is one of the most powerful — and most underused — security tools available to Solidity developers. While static analyzers like Slither catch known patterns, and symbolic execution finds path-dependent bugs, invariant fuzzing finds the class of vulnerabilities that neither can touch: business logic failures that only appear under specific state sequences.

This guide covers how to set up effective fuzz tests, which invariants to assert, and how to interpret results.


Why Fuzzing Finds What Static Analysis Misses

Static analysis reads code structure. It finds: reentrancy patterns, unprotected functions, known dangerous patterns. It cannot find:

These are semantic properties — they require knowing what the contract is supposed to do, not just what it does. Foundry fuzzing lets you encode these properties as invariants and then throw millions of random inputs at them.


Foundry Test Setup

# Install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup

# In your project
forge test            # unit tests
forge test --fuzz-runs 10000  # increase fuzz iterations

Project structure:

test/
├── unit/
│   └── VaultTest.t.sol      # standard unit tests
└── invariant/
    └── VaultInvariant.t.sol  # invariant fuzz tests

Anatomy of a Foundry Invariant Test

An invariant test has two parts:

  1. Handler contract: defines what actions the fuzzer can call
  2. Invariant contract: defines properties that must always hold
// test/invariant/VaultInvariant.t.sol
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Vault.sol";

// Handler: defines valid user actions the fuzzer can call
contract VaultHandler is Test {
    Vault public vault;
    address[] public actors;
    uint256 public ghost_totalDeposited;  // tracks off-chain state

    constructor(Vault _vault) {
        vault = _vault;
        // Create test users
        for (uint i = 0; i < 3; i++) {
            actors.push(makeAddr(string(abi.encodePacked("user", i))));
            vm.deal(actors[i], 100 ether);
        }
    }

    function deposit(uint256 actorSeed, uint256 amount) external {
        address actor = actors[bound(actorSeed, 0, actors.length - 1)];
        amount = bound(amount, 0.001 ether, 10 ether);

        vm.prank(actor);
        vault.deposit{value: amount}();
        ghost_totalDeposited += amount;
    }

    function withdraw(uint256 actorSeed, uint256 shares) external {
        address actor = actors[bound(actorSeed, 0, actors.length - 1)];
        uint256 userShares = vault.sharesOf(actor);
        if (userShares == 0) return;
        shares = bound(shares, 1, userShares);

        vm.prank(actor);
        vault.withdraw(shares);
    }
}

// Invariant test: properties that must hold after any sequence of calls
contract VaultInvariantTest is Test {
    Vault vault;
    VaultHandler handler;

    function setUp() public {
        vault = new Vault();
        handler = new VaultHandler(vault);
        // Tell the fuzzer to only call functions on the handler
        targetContract(address(handler));
    }

    // Invariant 1: vault's ETH balance >= total shares redeemable
    function invariant_solvency() public view {
        uint256 totalShareValue = vault.totalShares() > 0
            ? (address(vault).balance * vault.totalSupply()) / vault.totalShares()
            : 0;
        assertGe(address(vault).balance, vault.totalShares(), "Vault is insolvent");
    }

    // Invariant 2: no individual user can have more than 100% of supply
    function invariant_noUserExceedsSupply() public view {
        for (uint i = 0; i < handler.actors.length; i++) {
            address actor = handler.actors(i);
            assertLe(
                vault.sharesOf(actor),
                vault.totalShares(),
                "User shares exceed total supply"
            );
        }
    }

    // Invariant 3: totalSupply matches sum of all user balances (ghost variable check)
    function invariant_accountingIsSound() public view {
        // The vault's actual ETH should match what was deposited (minus withdrawals)
        // Ghost variables track this off-chain
        assertGe(
            address(vault).balance,
            0,
            "Vault balance went negative (impossible but good to assert)"
        );
    }
}

Run with:

forge test --match-contract VaultInvariantTest -v --fuzz-runs 50000

Real Vulnerability: Share Inflation Attack

The "share inflation" or "first depositor" attack is a class of vulnerabilities in ERC-4626-style vaults. Invariant fuzzing reliably finds it:

// VULNERABLE vault — first depositor can inflate share price
contract VulnerableVault {
    mapping(address => uint256) public shares;
    uint256 public totalShares;

    function deposit() external payable returns (uint256 sharesOut) {
        // Classic bug: when totalShares = 0, calculation gives attacker all shares cheaply
        if (totalShares == 0) {
            sharesOut = msg.value;
        } else {
            // Normal calculation based on current ratio
            sharesOut = (msg.value * totalShares) / address(this).balance;
        }
        shares[msg.sender] += sharesOut;
        totalShares += sharesOut;
    }
}

Attack sequence:
1. Attacker deposits 1 wei → gets 1 share (totalShares = 1)
2. Attacker directly sends 100 ETH to the contract (not via deposit) → inflates share value
3. Victim deposits 100 ETH → gets 0 shares (rounds down to 0 due to inflated ratio)
4. Attacker withdraws their 1 share → gets all ETH including victim's deposit

Invariant fuzzing will discover this because:
- The handler includes a directTransfer() action (sending ETH directly to the contract)
- The fuzzer will eventually sequence: deposit(small) → directTransfer(large) → victimDeposit
- The solvency invariant assertGe(vault.sharesOf(victim), 1) will fail

Fix: use OpenZeppelin's ERC4626 with virtual offset, or enforce a minimum initial deposit.


Invariants Worth Writing For Common Contract Types

ERC-20 Tokens

// Total supply never changes except via mint/burn
function invariant_supplyIsConserved() public view {
    uint256 sumBalances = 0;
    for (uint i = 0; i < holders.length; i++) {
        sumBalances += token.balanceOf(holders[i]);
    }
    assertEq(sumBalances, token.totalSupply(), "Supply not conserved");
}

// No address has more than totalSupply
function invariant_noOverflow() public view {
    for (uint i = 0; i < holders.length; i++) {
        assertLe(token.balanceOf(holders[i]), token.totalSupply());
    }
}

AMMs / DEXes

// Constant product invariant: k should never decrease (except via fees)
function invariant_kNeverDecreases() public view {
    (uint112 r0, uint112 r1,) = pair.getReserves();
    assertGe(uint256(r0) * uint256(r1), _lastK, "k decreased");
}

// Price impact is bounded: can't move price by more than X% in one swap
function invariant_priceImpactBounded() public view {
    // check the ratio hasn't changed beyond reasonable bounds
}

Lending Protocols

// Total borrows never exceed total supplied
function invariant_noFractionalReserveViolation() public view {
    assertLe(lending.totalBorrows(), lending.totalDeposited());
}

// Health factor of all borrowers stays above liquidation threshold
// (Run after each action, not just at end)
function invariant_noUndercollateralizedLoans() public view {
    for (uint i = 0; i < borrowers.length; i++) {
        if (lending.borrowed(borrowers[i]) > 0) {
            assertGe(
                lending.healthFactor(borrowers[i]),
                1e18,  // 1.0 in 18-decimal fixed point
                "Undercollateralized loan exists"
            );
        }
    }
}

Handler Design Tips

1. Use bound() to keep inputs in valid ranges:

// BAD: random uint256 almost always causes reverts before interesting state
function deposit(uint256 amount) external {
    vault.deposit{value: amount}();  // almost always reverts
}

// GOOD: bound inputs to plausible values
function deposit(uint256 amount) external {
    amount = bound(amount, 0.001 ether, 100 ether);
    vault.deposit{value: amount}();
}

2. Track "ghost variables" for off-chain state:

// Ghost variables shadow on-chain state for invariant checking
uint256 public ghost_depositsSum;
uint256 public ghost_withdrawalsSum;

function deposit(uint256 amount) external {
    vault.deposit{value: amount}();
    ghost_depositsSum += amount;  // track externally
}

// Then invariant can check:
// assertEq(address(vault).balance, ghost_depositsSum - ghost_withdrawalsSum)

3. Include "break glass" scenarios:

// Include actions that stress the system at boundaries
function depositMax() external {
    vault.deposit{value: 100 ether}();
}

function reentrantAttempt() external {
    // Try to re-enter vault during callback
    // If vault is vulnerable, this will break an invariant
}

4. Exclude invalid sequences with vm.assume():

function withdraw(uint256 shares) external {
    vm.assume(vault.sharesOf(address(this)) >= shares);
    vault.withdraw(shares);
}

Running Effective Fuzzing Campaigns

# foundry.toml
[fuzz]
runs = 10000        # per unit test
max_test_rejects = 65536

[invariant]
runs = 10000        # sequences per invariant
depth = 500         # calls per sequence (higher = finds deeper bugs)
fail_on_revert = false  # don't fail on reverts, only on invariant breaks

Recommended fuzz runs by contract complexity:
- Simple token: 10,000 runs, depth 100
- DeFi vault: 50,000 runs, depth 500
- AMM/DEX: 100,000+ runs, depth 1000

# Run with high coverage
forge test --match-contract Invariant -v \
  --fuzz-runs 100000 \
  --invariant-depth 1000

# Get coverage report
forge coverage --match-contract Invariant

What Foundry Fuzzing Can't Find

Fuzzing complements, but doesn't replace, other analysis layers:

What to find Best tool
Known vulnerability patterns Slither, Aderyn
Symbolic execution bugs (overflows, assertions) Mythril
- Foundry fuzzing, AI analysis
Cross-contract logic bugs AI analysis
Business logic under complex state Foundry fuzzing
Semantic intent violations AI analysis

A complete security pipeline runs all of these. Static analysis is fast (seconds), symbolic execution catches edge cases, and fuzzing finds emergent behaviors that only appear after sequences of calls.


ContractScan's Foundry Fuzz Integration

For teams that want fuzzing results as part of their unified scan report, ContractScan's Enterprise plan includes Foundry fuzz integration: run your existing forge test invariant suite and get the results merged into the same security report alongside Slither, Mythril, Semgrep, Aderyn, and AI analysis.

Findings from fuzz failures are correlated with static analysis results — if Slither flags a reentrancy pattern and your fuzz invariant breaks in the same function, the combined signal elevates the finding's severity.

For teams without existing fuzz tests, the free QuickScan at minimum surfaces static analysis and pattern-based findings — a fast first pass before investing in fuzz test development.


Foundry documentation: book.getfoundry.sh. Share invariant patterns that worked (or broke) for you — the DeFi security community benefits from concrete examples more than abstract advice.

Scan your contract now
Slither + AI analysis — Unlimited quick scans. No signup required.
Try Free Scan →