Foundry Fuzzing for Smart Contracts: Find Bugs Before Hackers Do

Foundry Fuzzing for Smart Contracts: Find Bugs Before Hackers Do

Unit tests verify specific known inputs. Fuzzing asks a different question: what happens when I throw ten thousand random inputs at this function? The answer is often "something you didn't expect," which in smart contract terms means drained liquidity, broken invariants, or permanently locked tokens.

Foundry's built-in fuzzer is one of the best tools available for finding these edge cases before an attacker does. This guide covers everything from basic fuzz tests to stateful invariant testing—the gold standard for auditing DeFi contracts.

Why Fuzzing Beats Manual Test Cases

A typical vault contract has a deposit function, a withdraw function, and an exchange rate calculation. A developer writes happy-path tests: deposit 100, get 100 shares. Withdraw 50 shares, get 50 tokens back. Tests pass. Contract ships.

Six months later, a researcher deposits 2^128 - 1 tokens in two separate transactions and the share calculation overflows to zero. They drain the vault for free.

No human would think to test 2^128 - 1 manually. A fuzzer would find it in under a minute.

Foundry's fuzzer is property-based: you define a property that must always hold (e.g., "a user can never withdraw more than they deposited"), and Foundry generates hundreds of random inputs trying to falsify that property. When it finds a counterexample, it shrinks it to the minimal input that reproduces the failure.

Setup

Install Foundry:

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

Initialize a project:

forge init fuzz-demo
cd fuzz-demo

Foundry projects use a different structure than Hardhat:

src/          ← Solidity contracts
test/         ← Solidity test files (yes, tests in Solidity)
script/       ← deployment scripts
foundry.toml  ← configuration

Basic Fuzz Testing

In Foundry, fuzz tests are regular Solidity test functions where you accept parameters. The fuzzer supplies random values.

// src/MathLib.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

library MathLib {
    /// @notice Calculates percentage of amount (basis points, 1 = 0.01%)
    function bps(uint256 amount, uint256 points) internal pure returns (uint256) {
        require(points <= 10_000, "points > 100%");
        return (amount * points) / 10_000;
    }

    /// @notice Safe average of two numbers without overflow
    function avg(uint256 a, uint256 b) internal pure returns (uint256) {
        return (a & b) + (a ^ b) / 2;
    }
}
// test/MathLib.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract MathLibTest is Test {
    using MathLib for uint256;

    // Fuzz test: Foundry runs this with hundreds of random (amount, points) pairs
    function testFuzz_bps_neverExceedsAmount(uint256 amount, uint256 points) public {
        // Bound inputs to valid range — Foundry's bound() clamps values
        points = bound(points, 0, 10_000);
        // amount bounded to avoid multiplication overflow
        amount = bound(amount, 0, type(uint128).max);

        uint256 result = MathLib.bps(amount, points);
        assertLe(result, amount, "bps result should never exceed original amount");
    }

    function testFuzz_avg_betweenInputs(uint256 a, uint256 b) public pure {
        uint256 result = MathLib.avg(a, b);
        // Result must be between a and b (inclusive)
        uint256 min = a < b ? a : b;
        uint256 max = a > b ? a : b;
        assertGe(result, min);
        assertLe(result, max);
    }

    function testFuzz_avg_commutative(uint256 a, uint256 b) public pure {
        assertEq(MathLib.avg(a, b), MathLib.avg(b, a));
    }
}

Run the tests:

forge test -v

Configure the number of runs in foundry.toml:

[fuzz]
runs = 1000        # default is 256
max_test_rejects = 65536
seed = "0xdeadbeef"  # optional: reproduce a specific run

Testing a Vault Contract with Fuzzing

Here's a realistic vault with a subtle bug intentionally included:

// src/SimpleVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract SimpleVault is ERC20 {
    IERC20 public immutable asset;

    constructor(IERC20 _asset) ERC20("Vault Share", "vSHARE") {
        asset = _asset;
    }

    function totalAssets() public view returns (uint256) {
        return asset.balanceOf(address(this));
    }

    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
        shares = _convertToShares(assets);
        asset.transferFrom(msg.sender, address(this), assets);
        _mint(receiver, shares);
    }

    function withdraw(uint256 shares, address receiver) external returns (uint256 assets) {
        assets = _convertToAssets(shares);
        _burn(msg.sender, shares);
        asset.transfer(receiver, assets);
    }

    function _convertToShares(uint256 assets) internal view returns (uint256) {
        uint256 supply = totalSupply();
        if (supply == 0) return assets;
        // BUG: integer division truncation can cause share inflation
        return (assets * supply) / totalAssets();
    }

    function _convertToAssets(uint256 shares) internal view returns (uint256) {
        uint256 supply = totalSupply();
        if (supply == 0) return shares;
        return (shares * totalAssets()) / supply;
    }
}
// test/SimpleVault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/SimpleVault.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor() ERC20("Mock", "MCK") {}
    function mint(address to, uint256 amount) external { _mint(to, amount); }
}

contract SimpleVaultFuzzTest is Test {
    SimpleVault vault;
    MockERC20 token;
    address alice = address(0xA11CE);
    address bob = address(0xB0B);

    function setUp() public {
        token = new MockERC20();
        vault = new SimpleVault(IERC20(address(token)));
    }

    function testFuzz_depositWithdrawRoundTrip(uint256 amount) public {
        amount = bound(amount, 1, 1e30);

        // Setup: mint tokens to alice
        token.mint(alice, amount);

        vm.startPrank(alice);
        token.approve(address(vault), amount);
        uint256 shares = vault.deposit(amount, alice);
        uint256 received = vault.withdraw(shares, alice);
        vm.stopPrank();

        // Property: withdrawing all shares should return close to deposited amount
        // Allowing 1 wei rounding error
        assertApproxEqAbs(received, amount, 1, "round trip should be lossless");
    }

    function testFuzz_noShareInflation(uint256 firstDeposit, uint256 secondDeposit) public {
        firstDeposit = bound(firstDeposit, 1e6, 1e24);
        secondDeposit = bound(secondDeposit, 1e6, 1e24);

        // Alice deposits first
        token.mint(alice, firstDeposit);
        vm.startPrank(alice);
        token.approve(address(vault), firstDeposit);
        uint256 aliceShares = vault.deposit(firstDeposit, alice);
        vm.stopPrank();

        // Bob deposits second
        token.mint(bob, secondDeposit);
        vm.startPrank(bob);
        token.approve(address(vault), secondDeposit);
        uint256 bobShares = vault.deposit(secondDeposit, bob);
        vm.stopPrank();

        // Property: shares should be proportional to deposits (within rounding)
        // If Bob deposited less, he gets fewer shares
        if (secondDeposit < firstDeposit) {
            assertLe(bobShares, aliceShares + 1);
        }
    }
}

Invariant Testing (Stateful Fuzzing)

Basic fuzzing tests individual functions. Invariant testing — Foundry's most powerful feature — runs sequences of random function calls and checks that certain properties hold after every call.

This catches reentrancy, state corruption, and complex multi-step exploits that single-function fuzzing can't find.

// test/SimpleVaultInvariant.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/SimpleVault.sol";
import "./SimpleVault.t.sol"; // reuse MockERC20

/// @notice Handler defines the actions the fuzzer can take
contract VaultHandler is Test {
    SimpleVault public vault;
    MockERC20 public token;

    address[] public actors;
    mapping(address => uint256) public depositedAmount;
    uint256 public totalDeposited;
    uint256 public totalWithdrawn;

    constructor(SimpleVault _vault, MockERC20 _token) {
        vault = _vault;
        token = _token;
        actors.push(address(0x1));
        actors.push(address(0x2));
        actors.push(address(0x3));
    }

    function deposit(uint256 actorSeed, uint256 amount) external {
        address actor = actors[actorSeed % actors.length];
        amount = bound(amount, 1, 1e24);

        token.mint(actor, amount);
        vm.startPrank(actor);
        token.approve(address(vault), amount);
        vault.deposit(amount, actor);
        vm.stopPrank();

        depositedAmount[actor] += amount;
        totalDeposited += amount;
    }

    function withdraw(uint256 actorSeed, uint256 sharesPct) external {
        address actor = actors[actorSeed % actors.length];
        uint256 shares = vault.balanceOf(actor);
        if (shares == 0) return;

        sharesPct = bound(sharesPct, 1, 100);
        uint256 toWithdraw = (shares * sharesPct) / 100;

        vm.prank(actor);
        uint256 received = vault.withdraw(toWithdraw, actor);
        totalWithdrawn += received;
    }
}

contract SimpleVaultInvariantTest is Test {
    SimpleVault vault;
    MockERC20 token;
    VaultHandler handler;

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

        // Tell Foundry to only call functions on the handler
        targetContract(address(handler));
    }

    /// @notice Total assets in vault >= total shares (no value loss)
    function invariant_assetsBackShares() public view {
        uint256 totalShares = vault.totalSupply();
        uint256 totalAssets = vault.totalAssets();

        if (totalShares > 0) {
            assertGe(
                totalAssets,
                totalShares,
                "invariant: assets must back shares"
            );
        }
    }

    /// @notice Vault token balance matches its reported totalAssets
    function invariant_balanceMatchesTotalAssets() public view {
        assertEq(
            token.balanceOf(address(vault)),
            vault.totalAssets(),
            "invariant: balance must match totalAssets"
        );
    }

    /// @notice No actor can have more shares than they're entitled to
    function invariant_callSummary() public view {
        console.log("Total deposited:", handler.totalDeposited());
        console.log("Total withdrawn:", handler.totalWithdrawn());
        console.log("Vault assets:", vault.totalAssets());
    }
}

Run invariant tests with increased depth:

[invariant]
runs = 256
depth = 500    # number of function calls per run
fail_on_revert = false  # normal reverts don't break the invariant

Foundry Cheatcodes for Fuzzing

Foundry's vm cheatcodes are essential for realistic fuzz scenarios:

// Impersonate any address
vm.prank(address(0xdead));

// Set ETH balance
vm.deal(address(0xdead), 100 ether);

// Set ERC-20 balance (via store slot manipulation)
deal(address(token), alice, 1000e18);

// Expect a revert with specific custom error
vm.expectRevert(
    abi.encodeWithSelector(MyContract.Unauthorized.selector, alice)
);

// Bound a value to a range (essential for fuzzing)
uint256 bounded = bound(rawValue, 1, type(uint128).max);

// Assume — discard inputs that don't satisfy preconditions
// (use sparingly — prefer bound() to avoid test rejection)
vm.assume(amount > 0);
vm.assume(amount < 1e30);

Reproducing Failures

When a fuzz test fails, Foundry prints the exact inputs:

[FAIL. Reason: Assertion failed.]
  [Fuzz] Counterexample: calldata=0x..., args=[115792089237316195423570985008687907853269984665640564039457584007913129639935]

Reproduce it deterministically:

forge test --match-test testFuzz_depositWithdrawRoundTrip \
  --fuzz-seed 0xdeadbeef

Foundry also saves failing seeds in ~/.foundry/fuzzing/ so reruns automatically include known failures.

Differential Testing

One advanced technique: test your Solidity implementation against a reference implementation written in Python or another language.

function testFuzz_matchesPythonCalculation(uint256 a, uint256 b) public {
    a = bound(a, 1, 1e18);
    b = bound(b, 1, 1e18);

    // Call your FFI Python script to compute the reference value
    string[] memory inputs = new string[](3);
    inputs[0] = "python3";
    inputs[1] = "scripts/reference_avg.py";
    inputs[2] = vm.toString(a);
    // ... compare with Solidity result
}

When to Use Foundry vs Hardhat

Use Foundry fuzzing for:

  • Numerical invariants (overflow, precision, rounding)
  • Access control boundaries
  • DeFi math (AMM curves, yield calculations)
  • Any contract where the input space is large

Use Hardhat unit tests for:

  • Event emission testing
  • Integration with JavaScript tooling
  • Tests that need to interact with off-chain components
  • Teams more comfortable in JavaScript

Most mature projects use both: Foundry for property-based and invariant testing, Hardhat for integration tests and scripts.

Summary

Foundry's fuzzer finds what unit tests miss. The workflow is:

  1. Write fuzz tests that accept random parameters and assert properties
  2. Use bound() to keep inputs in valid ranges
  3. Promote critical fuzz tests to invariant tests
  4. Write handlers to define what actions the fuzzer can sequence
  5. Increase runs and depth before audits or major releases

The best time to run fuzzing is before any deployment. The second-best time is right now.

Read more

Start now free