2026-03-29·12 min read

How to fuzz ERC-6909 multi-token contracts

How to fuzz ERC-6909 multi-token contracts

ERC-6909 is a minimal multi-token standard designed to replace the complexity of ERC-1155 with a cleaner, gas-efficient interface. Uniswap v4 adopted it for its pool accounting, and the standard is gaining traction across DeFi. Each ERC-6909 contract manages multiple token IDs through a single deployment, with per-ID balances, per-ID allowances, and a separate operator approval system. This dual-approval model and the shared-state design create a class of bugs that single-token testing completely misses.

Fuzzing is the right tool here because the bugs live in interactions between token IDs, between approval types, and across sequences of operations that no developer would write by hand. This guide walks through identifying the invariants that matter, writing properties with Chimera, building handlers that exercise the full multi-token surface, and running campaigns.

<a href="/request-audit" class="cta-button">Get expert multi-token fuzzing</a>

What ERC-6909 guarantees

The EIP-6909 specification defines a multi-token interface with two distinct approval mechanisms. An approve() call grants a spender a specific allowance for a single token ID, while setOperator() grants blanket permission across all IDs. The standard also defines transfer() for direct sends and transferFrom() for delegated transfers.

The guarantees that matter most for security:

  1. Per-ID accounting. Each token ID has independent balances. A transfer of ID 1 must not change any balance of ID 2.
  2. Supply conservation. For each token ID, the sum of all holder balances must equal the total supply. Tokens cannot appear or disappear.
  3. Allowance consumption. A transferFrom() using per-ID allowance must deduct the transferred amount from the caller's allowance, unless the allowance is set to type(uint256).max.
  4. Operator scope. An operator can transfer any amount of any token ID on behalf of the owner, without consuming per-ID allowances.
  5. No zero-address holdings. Tokens must not be transferable to or held by the zero address.

These look straightforward, but custom mint/burn logic, fee-on-transfer variants, and callback extensions break them regularly. The interaction between operator approvals and per-ID allowances is where most bugs hide.

Key invariants

Before writing code, we need to define the properties that should hold after every transaction, regardless of what sequence the fuzzer generates.

Per-ID supply conservation

The most critical invariant. For every token ID, the sum of all balances must equal the total supply tracked by the contract.

sum(balanceOf(actor, id) for all actors) == totalSupply(id)

We track expected supplies using ghost variables that mirror every mint, burn, and transfer. Any divergence between the ghost and the contract means tokens appeared or vanished outside the expected paths.

Cross-ID isolation

A transfer of token ID i must not change any balance of token ID j. This sounds obvious, but implementations that pack multiple IDs into shared storage slots can violate it through bit-masking errors.

balanceOf(actor, j)_before == balanceOf(actor, j)_after  // for j != i

Allowance consumption correctness

When a non-operator spender calls transferFrom(), the allowance must decrease by the transferred amount. The exception is infinite approval (type(uint256).max), which must not be consumed.

allowance_after == allowance_before - amount  // unless infinite

Operator vs per-ID approval scope

An operator (isOperator(owner, spender) == true) should be able to transfer without any per-ID allowance. Conversely, revoking operator status must not affect existing per-ID allowances.

No zero-address holdings

Transfers to address(0) should revert. The zero address must never hold a positive balance for any token ID.

Setting up Chimera

We use the Chimera framework so our properties run on Foundry, Echidna, and Medusa without changes. If you haven't set up Chimera before, our beginner's guide covers the full installation.

The file structure:

test/
  invariants/
    Setup.sol          // Deploy token, fund actors, init ghost state
    TargetFunctions.sol // Handler functions the fuzzer calls
    Properties.sol      // Invariant checks
    CryticTester.sol    // Entrypoint for Echidna/Medusa

Setup

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {MockERC6909} from "./mocks/MockERC6909.sol";
import {BaseSetup} from "@chimera/BaseSetup.sol";

abstract contract Setup is BaseSetup {
    MockERC6909 internal token;
    address[] internal actors;
    uint256[] internal tokenIds;

    // Ghost state for supply tracking
    mapping(uint256 => uint256) internal ghostTotalSupply;

    function setup() internal virtual override {
        token = new MockERC6909();

        tokenIds.push(1);
        tokenIds.push(2);
        tokenIds.push(3);

        actors.push(address(0x10001));
        actors.push(address(0x10002));
        actors.push(address(0x10003));

        // Mint initial balances for each actor and each ID
        for (uint256 i = 0; i < actors.length; i++) {
            for (uint256 j = 0; j < tokenIds.length; j++) {
                token.mint(actors[i], tokenIds[j], 1_000_000e18);
                ghostTotalSupply[tokenIds[j]] += 1_000_000e18;
            }
        }
    }
}

We seed three token IDs and three actors. The ghost mapping ghostTotalSupply tracks expected supply for each ID. Every handler that mints or burns must update the ghost in lockstep, so any mismatch points directly to a contract-level accounting bug.

Writing properties

Each property is a view function returning bool. The fuzzer calls these after every transaction and flags a violation if any returns false.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Setup} from "./Setup.sol";

abstract contract Properties is Setup {
    // P-1: Per-ID supply conservation
    function invariant_supplyConservation() public view returns (bool) {
        for (uint256 i = 0; i < tokenIds.length; i++) {
            uint256 id = tokenIds[i];
            uint256 balanceSum = 0;
            for (uint256 j = 0; j < actors.length; j++) {
                balanceSum += token.balanceOf(actors[j], id);
            }
            // Include contract's own balance if applicable
            balanceSum += token.balanceOf(address(this), id);
            if (balanceSum != ghostTotalSupply[id]) return false;
        }
        return true;
    }

    // P-2: Cross-ID isolation (tracked per call in handlers)
    // See handler_transferCheckIsolation below

    // P-3: No zero-address holdings
    function invariant_noZeroAddressBalance() public view returns (bool) {
        for (uint256 i = 0; i < tokenIds.length; i++) {
            if (token.balanceOf(address(0), tokenIds[i]) > 0) return false;
        }
        return true;
    }

    // P-4: Operator approval does not grant per-ID allowance
    function invariant_operatorDoesNotGrantAllowance() public view returns (bool) {
        for (uint256 i = 0; i < actors.length; i++) {
            for (uint256 j = 0; j < actors.length; j++) {
                if (i == j) continue;
                if (!token.isOperator(actors[i], actors[j])) continue;
                // Being an operator should not increase per-ID allowance
                for (uint256 k = 0; k < tokenIds.length; k++) {
                    uint256 allowance = token.allowance(actors[i], actors[j], tokenIds[k]);
                    // Unless explicitly approved, allowance should be 0
                    // This checks the separation of concerns
                    if (allowance > 0 && allowance != type(uint256).max) {
                        // Non-zero allowance is fine if it was explicitly set
                        // We can't distinguish here, so this is a weaker check
                    }
                }
            }
        }
        return true;
    }

    // P-5: Supply matches ghost
    function invariant_supplyMatchesGhost() public view returns (bool) {
        for (uint256 i = 0; i < tokenIds.length; i++) {
            uint256 id = tokenIds[i];
            if (token.totalSupply(id) != ghostTotalSupply[id]) return false;
        }
        return true;
    }
}

P-1 is the core conservation check. It sums balances across all known actors for each token ID and compares against the ghost. P-3 verifies the zero-address restriction. P-5 cross-checks the contract's own totalSupply() against our independent ghost tracker.

For cross-ID isolation (P-2), we check it inline in the transfer handler, since we need before/after snapshots.

Writing target functions

Target functions are what the fuzzer actually calls. Each handler wraps a token operation with bounded inputs.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Properties} from "./Properties.sol";

abstract contract TargetFunctions is Properties {

    function handler_transfer(uint256 actorSeed, uint256 idSeed, uint256 amount) public {
        address from = actors[actorSeed % actors.length];
        uint256 id = tokenIds[idSeed % tokenIds.length];
        address to = actors[(actorSeed / 3) % actors.length];
        uint256 bal = token.balanceOf(from, id);
        if (bal == 0) return;
        amount = bound(amount, 1, bal);

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

    function handler_transferFrom(
        uint256 actorSeed,
        uint256 idSeed,
        uint256 amount
    ) public {
        address spender = actors[actorSeed % actors.length];
        address from = actors[(actorSeed / 3) % actors.length];
        address to = actors[(actorSeed / 9) % actors.length];
        uint256 id = tokenIds[idSeed % tokenIds.length];

        uint256 bal = token.balanceOf(from, id);
        uint256 allowed = token.allowance(from, spender, id);
        bool isOp = token.isOperator(from, spender);

        if (bal == 0) return;
        if (!isOp && allowed == 0) return;

        uint256 maxTransfer = isOp ? bal : min(bal, allowed);
        amount = bound(amount, 1, maxTransfer);

        vm.prank(spender);
        token.transferFrom(from, to, id, amount);
    }

    function handler_approve(
        uint256 actorSeed,
        uint256 idSeed,
        uint256 amount
    ) public {
        address owner = actors[actorSeed % actors.length];
        address spender = actors[(actorSeed / 3) % actors.length];
        uint256 id = tokenIds[idSeed % tokenIds.length];
        amount = bound(amount, 0, type(uint256).max);

        vm.prank(owner);
        token.approve(spender, id, amount);
    }

    function handler_setOperator(uint256 actorSeed, bool approved) public {
        address owner = actors[actorSeed % actors.length];
        address operator = actors[(actorSeed / 3) % actors.length];

        vm.prank(owner);
        token.setOperator(operator, approved);
    }

    function handler_mint(uint256 actorSeed, uint256 idSeed, uint256 amount) public {
        address to = actors[actorSeed % actors.length];
        uint256 id = tokenIds[idSeed % tokenIds.length];
        amount = bound(amount, 1, 100_000e18);

        token.mint(to, id, amount);
        ghostTotalSupply[id] += amount;
    }

    function handler_burn(uint256 actorSeed, uint256 idSeed, uint256 amount) public {
        address from = actors[actorSeed % actors.length];
        uint256 id = tokenIds[idSeed % tokenIds.length];
        uint256 bal = token.balanceOf(from, id);
        if (bal == 0) return;
        amount = bound(amount, 1, bal);

        token.burn(from, id, amount);
        ghostTotalSupply[id] -= amount;
    }

    // Inline property: cross-ID isolation
    function handler_transferCheckIsolation(
        uint256 actorSeed,
        uint256 idSeed,
        uint256 amount
    ) public {
        address from = actors[actorSeed % actors.length];
        address to = actors[(actorSeed / 3) % actors.length];
        uint256 transferId = tokenIds[idSeed % tokenIds.length];

        uint256 bal = token.balanceOf(from, transferId);
        if (bal == 0) return;
        amount = bound(amount, 1, bal);

        // Snapshot balances for other IDs
        uint256[] memory beforeFrom = new uint256[](tokenIds.length);
        uint256[] memory beforeTo = new uint256[](tokenIds.length);
        for (uint256 i = 0; i < tokenIds.length; i++) {
            beforeFrom[i] = token.balanceOf(from, tokenIds[i]);
            beforeTo[i] = token.balanceOf(to, tokenIds[i]);
        }

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

        // Verify other IDs are unchanged
        for (uint256 i = 0; i < tokenIds.length; i++) {
            if (tokenIds[i] == transferId) continue;
            assert(token.balanceOf(from, tokenIds[i]) == beforeFrom[i]);
            if (from != to) {
                assert(token.balanceOf(to, tokenIds[i]) == beforeTo[i]);
            }
        }
    }

    function min(uint256 a, uint256 b) internal pure returns (uint256) {
        return a < b ? a : b;
    }
}

A few things to notice:

  • handler_mint and handler_burn update the ghost mapping in lockstep with the contract call. If the ghost drifts from the contract, invariant_supplyMatchesGhost catches it.
  • handler_transferCheckIsolation snapshots all token ID balances before a transfer, executes it, then asserts that non-transferred IDs are untouched. This is the cross-ID isolation check that static invariants can't express.
  • handler_setOperator exercises the operator approval path, letting the fuzzer explore sequences where operator status changes mid-campaign.

Running the campaign

Foundry (quick smoke test)

forge test --match-contract CryticTester --fuzz-runs 10000

This takes under a minute and catches the most obvious violations. Good for iteration during development.

Medusa (broad coverage)

{
  "fuzzing": {
    "targetContracts": ["CryticTester"],
    "testLimit": 500000,
    "callSequenceLength": 100,
    "workers": 8,
    "corpusDirectory": "corpus-medusa"
  }
}
medusa fuzz

Medusa's parallel workers explore the multi-token state space fast. With three token IDs and three actors, the combinatorial surface is large enough that parallel exploration matters.

Echidna (deep exploration)

# echidna.yaml
testMode: assertion
testLimit: 2000000
seqLen: 150
corpusDir: "corpus-echidna"
echidna . --contract CryticTester --config echidna.yaml

Echidna's directed exploration and sequence shrinking give you the cleanest reproducers. For multi-token contracts, the shrunk sequences reveal exactly which combination of IDs and operations triggered the violation. For a deep dive on when to use each fuzzer, see our Echidna vs Medusa comparison.

Interpreting results

When a property fails, you get a call sequence that triggered the violation. Here's what to look for with ERC-6909 contracts.

Supply conservation failures

If invariant_supplyConservation or invariant_supplyMatchesGhost fails, tokens are being created or destroyed outside of mint/burn paths. Common causes:

  • Unchecked arithmetic in transfer logic. A subtraction that wraps around, inflating the sender's balance.
  • Mint/burn callbacks that re-enter and double-count. If your implementation has transfer hooks, a callback might trigger a second mint before the first completes.
  • Storage packing errors. Implementations that pack multiple token ID balances into a single slot can corrupt adjacent IDs during writes.

Cross-ID isolation failures

If handler_transferCheckIsolation asserts false, a transfer of one token ID changed the balance of another. This is almost always a storage layout bug. Look at how the implementation stores balances. If it uses a flat mapping (mapping(uint256 => mapping(address => uint256))), isolation should hold naturally. If it uses packed storage or custom encoding, the packing logic is the first place to check.

Operator/allowance confusion

If the fuzzer finds a sequence where a non-operator, zero-allowance address successfully calls transferFrom(), the approval check logic has a bug. Look for conditions where the implementation combines isOperator() and allowance() checks with the wrong boolean logic.

Beyond the basics

Once your core ERC-6909 properties are passing, consider these extensions.

  • Add protocol-specific IDs. If your token IDs represent specific assets (like Uniswap v4 pool positions), add handlers that create new IDs dynamically and verify properties hold for IDs the fuzzer creates.
  • Test operator revocation sequences. Add handlers that toggle operator status mid-sequence and verify that previously authorized transfers now revert.
  • Combine with downstream protocols. If your ERC-6909 token is used as collateral elsewhere, include the downstream protocol in the setup and verify cross-protocol invariants.
  • Run in CI. Set up continuous fuzzing so every PR gets fuzzed.
  • ERC-1155 comparison. For ERC-1155 implementations, see our ERC-1155 fuzzing guide which covers batch atomicity and callback safety — properties that don't apply to ERC-6909 but matter if you're migrating between standards.

ERC-6909's simplicity is deceptive. The dual approval system and multi-ID accounting give fuzzers a rich state space to explore, and that's exactly where the interesting bugs live.

If you're building on ERC-6909 and want a complete invariant suite tailored to your implementation, request an audit with Recon. We've built property suites for multi-token systems including Uniswap v4 integrations.

Related Posts

Related Glossary Terms

Fuzz your multi-token contract with Recon