tiancaijb

Most hacks happen in edge cases the dev never considered. Fuzz testing throws random inputs at your contracts until something breaks — and Foundry makes it fast enough that you run it before every commit. Here are the patterns I actually use.

Getting Started

forge install tiancaijb366-pixel/foundry-security-tests

Enter fullscreen mode Exit fullscreen mode

The companion repo has all these examples runnable. Fork it, run forge test, then rip out the patterns for your own contracts.


1. Basic Fuzzing with bound / vm.assume

Don't write ten test_RevertIf_* functions. Give the fuzzer a range and let it find the off-by-one.

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

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

contract TokenFuzzTest is Test {
    Token t;

    function setUp() public {
        t = new Token(1_000_000e18);
    }

    function testFuzz_Transfer_Bounds(address sender, address to, uint256 amount) public {
        // `bound` keeps inputs practical
        amount = bound(amount, 1, t.balanceOf(sender));

        // `vm.assume` filters — use it sparingly (slows the fuzzer)
        vm.assume(sender != address(0) && to != address(0) && sender != to);

        vm.prank(sender);
        t.transfer(to, amount);

        assertGe(t.balanceOf(sender), 0);
        assertEq(t.totalSupply(), 1_000_000e18);
    }
}

Enter fullscreen mode Exit fullscreen mode

Key point: bound is faster than vm.assume — it narrows the range instead of discarding inputs. Use assume only for relationships the fuzzer can't infer (e.g., sender != to).


2. Invariant Testing: ERC20 totalSupply

Invariant tests check a property holds across random sequences of calls. They catch state-machine bugs that single-function fuzzing misses.

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

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

contract TokenInvariantTest is StdInvariant, Test {
    Token t;

    function setUp() public {
        t = new Token(1_000_000e18);
        targetContract(address(t));
    }

    // This should _always_ be true
    function invariant_totalSupply() public {
        assertEq(t.totalSupply(), 1_000_000e18);
    }
}

Enter fullscreen mode Exit fullscreen mode

Run with:

forge test --match-test invariant -vvv

Enter fullscreen mode Exit fullscreen mode

Add --fuzz-runs 50000 for serious fuzzing. On a commodity laptop that runs in ~30s and catches things you'd never write a unit test for.

Ghost variable pattern: When the invariant involves contract state over time, track a ghost variable in the test contract that mirrors expected state after every handler call.


3. Reentrancy Detection via Fuzz

Foundry rolls back state after every fuzz run, so you can brute-force reentrancy paths without setting up a separate exploit contract for each scenario.

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

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

contract ReentrancyFuzzTest is Test {
    Vault v;

    // Track the reentrancy attempt
    bool public attackAttempted;
    uint256 public balanceBefore;

    receive() external payable {
        if (attackAttempted) return; // only re-enter once
        attackAttempted = true;

        // Try to drain before the first call finishes
        v.withdraw(balanceBefore);
    }

    function testFuzz_Reentrancy(uint256 depositAmount) public {
        depositAmount = bound(depositAmount, 1 ether, 100 ether);

        // Fund victim
        v.deposit{value: depositAmount}();
        balanceBefore = depositAmount;

        // Attack from this contract
        attackAttempted = false;
        v.withdraw(depositAmount);

        // If reentrancy worked, vault balance would be < 0
        assertLe(address(v).balance, depositAmount);
    }
}

Enter fullscreen mode Exit fullscreen mode

Why this works: Foundry isolates each fuzz run. You don't need expectRevert — just check the final state. If totalSupply or balance diverged, the fuzzer found a path.


4. Access Control Fuzzing

The most common finding in real audits: an onlyOwner modifier that doesn't cover all state-changing paths. Fuzz every function from every caller.

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

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

contract AccessControlFuzzTest is Test {
    Vault v;

    address owner = makeAddr("owner");
    address attacker = makeAddr("attacker");

    function setUp() public {
        vm.prank(owner);
        v = new Vault();

        deal(attacker, 100 ether);
    }

    // Fuzz every state-changing function as an attacker
    function testFuzz_AccessControl_Withdraw(uint256 amount) public {
        vm.assume(amount > 0 && amount <= 100 ether);

        vm.prank(attacker);
        vm.expectRevert(); // should always revert for non-owner
        v.emergencyWithdraw(amount);
    }

    function testFuzz_AccessControl_Pause(bool paused) public {
        vm.prank(attacker);
        vm.expectRevert();
        v.setPaused(paused);
    }

    function testFuzz_AccessControl_Mint(uint256 amount) public {
        amount = bound(amount, 0, 1_000_000e18);

        vm.prank(attacker);
        vm.expectRevert();
        v.mint(attacker, amount);
    }
}

Enter fullscreen mode Exit fullscreen mode

Pro tip: Build a handler contract that wraps every permissioned function and call it from both privileged and unprivileged addresses in your invariant test suite. One function per access level.


5. Oracle Manipulation Fuzzing

DeFi exploits almost always involve price oracles returning manipulated values. Fuzz the oracle input and check what happens to your core accounting.

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

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

contract OracleManipulationFuzzTest is Test {
    LendingPool pool;
    MockOracle oracle;

    address user = makeAddr("user");

    function setUp() public {
        oracle = new MockOracle(1000e8); // ETH/USD = $1000
        pool = new LendingPool(address(oracle));
        deal(user, 100 ether);
    }

    // What happens if the oracle price swings wildly?
    function testFuzz_OracleManipulation(uint256 manipulatedPrice, uint256 collateral) public {
        collateral = bound(collateral, 1 ether, 50 ether);
        manipulatedPrice = bound(manipulatedPrice, 1e8, 100_000e8); // $1 to $100k

        // User deposits collateral
        vm.prank(user);
        pool.deposit{value: collateral}();

        // Oracle is manipulated (flash loan, sandwich, etc.)
        oracle.setPrice(manipulatedPrice);

        // User borrows against inflated collateral — or gets liquidated unfairly
        vm.prank(user);
        pool.borrow();

        // Check: can the pool cover all deposits?
        assertGe(address(pool).balance, pool.totalDeposits());
    }
}

Enter fullscreen mode Exit fullscreen mode

What this catches: If your borrow() or liquidation math assumes prices stay within 5% of the previous value, the fuzzer will find the exact value that breaks it. Then you add a circuit breaker or TWAP window.


Running the Full Suite

# Standard fuzz (default 256 runs per test)
forge test

# Heavy fuzz (closer to what auditors run)
forge test --fuzz-runs 50000 --ffi

# Invariant tests (sequences of calls)
forge test --match-test invariant --fuzz-runs 50000

Enter fullscreen mode Exit fullscreen mode

On CI, run the light suite on every push and the heavy suite nightly.


Useful? Check out these resources: