Ir al contenido principal
BlockchainMar 28, 2026

Deep EVM #19: Property-Based Testing for Smart Contracts — Fuzzing with Foundry

OS
Open Soft Team

Engineering Team

Why Fuzzing Catches Bugs That Unit Tests Miss

Unit tests verify specific scenarios: transfer 100 tokens from Alice to Bob, check balance is updated. But smart contracts face adversarial inputs from any address, with any value, in any order. A unit test that checks 10 scenarios cannot compete with a fuzzer that generates 100,000 random inputs looking for edge cases.

Property-based testing flips the paradigm. Instead of specifying expected outputs for specific inputs, you define properties that must hold for ALL inputs. The fuzzer then tries to find a counterexample:

// Unit test: specific input
function test_transfer() public {
    token.transfer(bob, 100);
    assertEq(token.balanceOf(bob), 100);
}

// Property-based test: for ALL inputs
function testFuzz_transfer(address to, uint256 amount) public {
    uint256 totalBefore = token.balanceOf(alice) + token.balanceOf(to);
    vm.prank(alice);
    token.transfer(to, amount);
    uint256 totalAfter = token.balanceOf(alice) + token.balanceOf(to);
    assertEq(totalBefore, totalAfter, "Conservation of tokens");
}

Foundry’s fuzzer uses intelligent strategies: it starts with boundary values (0, 1, type(uint256).max), then mixes random values, then uses coverage-guided mutation to explore new code paths.

Configuring the Fuzzer

Foundry’s fuzz configuration controls the depth and breadth of exploration:

# foundry.toml
[profile.default.fuzz]
runs = 10000            # Number of random inputs per test
max_test_rejects = 100000  # Max rejected inputs before failure
seed = 0x42             # Fixed seed for reproducibility
dictionary_weight = 40  # Weight for dictionary-based mutations

[profile.ci.fuzz]
runs = 100000           # More runs in CI

The dictionary_weight parameter controls how often the fuzzer reuses interesting values discovered during execution (like addresses found in storage) versus generating completely random inputs.

bound() vs assume(): Constraining Inputs

Fuzzing generates arbitrary inputs, but your contract may have valid input ranges. There are two ways to handle this:

assume(): Skip Invalid Inputs

function testFuzz_withdraw(uint256 amount) public {
    vm.assume(amount > 0);
    vm.assume(amount <= token.balanceOf(alice));
    // Test with valid amounts only
}

assume() discards inputs that do not meet the condition. Problem: if most inputs are invalid, the fuzzer wastes most of its runs. With amount <= balance, approximately 50% of random uint256 values are too large, and the fuzzer will reject them.

bound(): Transform Invalid Inputs

function testFuzz_withdraw(uint256 amount) public {
    amount = bound(amount, 1, token.balanceOf(alice));
    // Every input is valid — no wasted runs
}

bound() maps any uint256 into the valid range using modular arithmetic. This is almost always preferable because no runs are wasted. Use assume() only for complex conditions that cannot be expressed as a range.

Invariant Testing: Stateful Fuzzing

Fuzz tests above are stateless — each run is independent. Invariant tests are stateful: the fuzzer calls a sequence of functions in random order, then checks that invariants hold after each call:

// test/invariant/TokenInvariant.t.sol
contract TokenInvariantTest is Test {
    SimpleToken token;
    TokenHandler handler;

    function setUp() public {
        token = new SimpleToken();
        handler = new TokenHandler(token);
        targetContract(address(handler));
    }

    // This MUST hold after ANY sequence of calls
    function invariant_totalSupplyConstant() public view {
        assertEq(
            token.totalSupply(),
            1_000_000e18,
            "Total supply must never change"
        );
    }

    function invariant_sumOfBalancesEqTotalSupply() public view {
        uint256 sum = token.balanceOf(address(handler.alice()))
            + token.balanceOf(address(handler.bob()))
            + token.balanceOf(address(handler.charlie()));
        assertEq(sum, token.totalSupply(), "Balances must sum to total");
    }
}

// Handler bounds and guides the fuzzer
contract TokenHandler is Test {
    SimpleToken token;
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");
    address public charlie = makeAddr("charlie");

    constructor(SimpleToken _token) {
        token = _token;
    }

    function transfer(
        uint256 fromSeed,
        uint256 toSeed,
        uint256 amount
    ) external {
        address from = _selectUser(fromSeed);
        address to = _selectUser(toSeed);
        amount = bound(amount, 0, token.balanceOf(from));

        vm.prank(from);
        token.transfer(to, amount);
    }

    function _selectUser(uint256 seed) internal view returns (address) {
        uint256 index = seed % 3;
        if (index == 0) return alice;
        if (index == 1) return bob;
        return charlie;
    }
}

Configure invariant testing:

[profile.default.invariant]
runs = 256          # Number of call sequences
depth = 128         # Calls per sequence
fail_on_revert = false  # Don't fail on handler reverts

The fuzzer generates 256 sequences of 128 random function calls, then checks all invariants after each call. This is enormously powerful for finding state-dependent bugs.

Differential Testing: Huff vs Yul vs Solidity

Differential testing is the gold standard for verifying low-level EVM implementations. Deploy three implementations of the same specification and verify they produce identical results for all inputs:

contract DifferentialFuzzTest is Test {
    IToken huffToken;
    IToken yulToken;
    IToken solToken;

    function setUp() public {
        huffToken = IToken(HuffDeployer.deploy("HuffToken"));
        yulToken = IToken(deployYulContract("YulToken"));
        solToken = IToken(address(new SolidityToken()));

        // Give each implementation identical initial state
        address[3] memory users = [alice, bob, charlie];
        for (uint i = 0; i < users.length; i++) {
            _setBalance(address(huffToken), users[i], 1000e18);
            _setBalance(address(yulToken), users[i], 1000e18);
            _setBalance(address(solToken), users[i], 1000e18);
        }
    }

    function testFuzz_transfer_differential(
        uint8 fromIdx,
        uint8 toIdx,
        uint256 amount
    ) public {
        address from = _user(fromIdx);
        address to = _user(toIdx);
        amount = bound(amount, 0, 1000e18);

        // Call all three
        vm.prank(from);
        (bool s1,) = address(huffToken).call(
            abi.encodeCall(IToken.transfer, (to, amount))
        );

        vm.prank(from);
        (bool s2,) = address(yulToken).call(
            abi.encodeCall(IToken.transfer, (to, amount))
        );

        vm.prank(from);
        (bool s3,) = address(solToken).call(
            abi.encodeCall(IToken.transfer, (to, amount))
        );

        // All must agree on success/failure
        assertEq(s1, s2, "Huff vs Yul success mismatch");
        assertEq(s2, s3, "Yul vs Solidity success mismatch");

        // If successful, balances must match
        if (s1) {
            assertEq(
                huffToken.balanceOf(from),
                solToken.balanceOf(from),
                "Sender balance mismatch"
            );
            assertEq(
                huffToken.balanceOf(to),
                solToken.balanceOf(to),
                "Recipient balance mismatch"
            );
        }
    }
}

Real Bugs Caught by Fuzzing

Fuzzing has a proven track record of catching critical bugs:

Bug 1: Phantom Overflow in Huff ERC20

A Huff ERC20 implementation added balances without checking for overflow. The fuzzer found that transferring type(uint256).max - balance + 1 tokens would wrap the recipient’s balance to near-zero:

Counterexample: transfer(to=bob, amount=115792089237316195423570985008687907853269984665640564039457584007913129639436)

Bug 2: Self-Transfer Double-Spend

When from == to, a naive Huff implementation would subtract from the balance slot, then add to the same slot, effectively doubling the tokens:

// Fuzzer found from == to edge case
function testFuzz_selfTransfer(uint256 amount) public {
    amount = bound(amount, 1, token.balanceOf(alice));
    uint256 before = token.balanceOf(alice);
    vm.prank(alice);
    token.transfer(alice, amount);
    assertEq(token.balanceOf(alice), before, "Self-transfer must not change balance");
}

Bug 3: Zero-Address Mint

The fuzzer discovered that transferring to address(0) in a Huff token did not revert, effectively burning tokens without updating total supply. This broke the sum(balances) == totalSupply invariant.

Integrating Fuzzing into CI

Run fuzz tests with higher iteration counts in CI:

# .github/workflows/test.yml
jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: foundry-rs/foundry-toolchain@v1
      - name: Run fuzz tests
        run: |
          forge test --match-path "test/fuzz/*" \
            --fuzz-runs 100000 \
            --fuzz-seed ${{ github.run_id }}
      - name: Run invariant tests
        run: |
          forge test --match-path "test/invariant/*" \
            --invariant-runs 512 \
            --invariant-depth 256

Using github.run_id as the seed ensures reproducibility while varying inputs across runs.

Conclusion

Fuzzing transforms smart contract testing from “does it work for these 10 cases?” to “can I break it with any of 100,000 random inputs?” Property-based testing with bound(), invariant testing with stateful sequences, and differential testing across implementations form a comprehensive safety net. For Huff and Yul contracts where the compiler provides no safety guarantees, fuzzing is not optional — it is the primary defense against production bugs.