2026-04-02·11 min read

How to fuzz ERC-1155 multi-token contracts

How to fuzz ERC-1155 multi-token contracts

ERC-1155 is the multi-token standard behind most NFT marketplaces, gaming platforms, and badge systems. A single contract manages fungible and non-fungible token IDs, supports batch operations, and mandates receiver callbacks. This combination of batch atomicity, callback hooks, and shared-state accounting creates a testing surface that single-token approaches can't cover.

Fuzzing ERC-1155 is about verifying that batch operations are truly atomic and that callbacks don't open reentrancy windows. Per-ID accounting must stay consistent across every combination of single and batch transfers. This guide covers the invariants, a Chimera setup with callback-aware handlers, and campaigns that stress the paths where ERC-1155 implementations break.

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

What ERC-1155 guarantees

The EIP-1155 specification defines a multi-token interface with both individual and batch operations. safeTransferFrom() moves a single token ID, while safeBatchTransferFrom() moves multiple IDs in one call. Both require the recipient to implement IERC1155Receiver if it's a contract, and both must revert if the receiver rejects the transfer.

The guarantees that matter most for security:

  1. Per-ID supply conservation. For each token ID, the sum of all balances must equal the total supply. No tokens appear or vanish.
  2. Batch atomicity. A batch transfer is all-or-nothing. If the transfer of any single ID in the batch fails (insufficient balance, receiver rejection), the entire batch reverts.
  3. Callback safety. If the receiver is a contract, onERC1155Received() or onERC1155BatchReceived() must be called. If the receiver doesn't implement the interface or returns the wrong selector, the transfer must revert.
  4. Approval-for-all scope. setApprovalForAll() grants an operator permission to transfer all token IDs on behalf of the owner. This is a binary toggle with no per-ID granularity.
  5. Batch/individual consistency. balanceOfBatch() must return the same values as calling balanceOf() individually for each account/ID pair.
  6. No reentrancy via callbacks. The mandatory callbacks create a reentrancy surface. The contract must handle callbacks safely, whether through reentrancy guards or checks-effects-interactions ordering.

The callback requirement is what makes ERC-1155 different from simpler multi-token standards. Every transfer to a contract triggers external code execution, and the contract's state must be consistent before that callback fires.

Key invariants

Per-ID supply conservation

For every token ID, the sum of all holder balances must equal the total supply. We track this with ghost variables that mirror every mint, burn, and transfer.

sum(balanceOf(addr, id) for all addrs) == totalSupply(id)

Batch atomicity

A batch transfer of IDs [1, 2, 3] with amounts [10, 20, 30] must either complete fully or not at all. If the sender has insufficient balance for ID 2, IDs 1 and 3 must not be transferred either.

// If batch reverts, no balances change for any ID in the batch
balanceOf(from, ids[i])_after == balanceOf(from, ids[i])_before  // for all i

Callback safety

If the receiver is a contract that doesn't implement IERC1155Receiver, the transfer must revert. If the receiver returns the wrong magic value, the transfer must revert.

Approval-for-all scope

An approved operator can transfer any token ID on behalf of the owner. Revoking approval must immediately prevent further transfers.

Batch/individual balance consistency

balanceOfBatch([addr1, addr2], [id1, id2]) must return the same values as [balanceOf(addr1, id1), balanceOf(addr2, id2)].

Setting up Chimera

We use the Chimera framework with a setup that includes mock receivers for testing callback behavior. The beginner's guide covers basic installation.

The file structure:

test/
  invariants/
    Setup.sol          // Deploy token, receivers, fund actors
    TargetFunctions.sol // Single/batch transfers, approvals
    Properties.sol      // Conservation, consistency, callback checks
    CryticTester.sol    // Entrypoint for Echidna/Medusa
  mocks/
    GoodReceiver.sol   // Accepts all transfers
    BadReceiver.sol    // Rejects all transfers
    ReentrantReceiver.sol // Attempts reentrancy on callback

Setup

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

import {MockERC1155} from "./mocks/MockERC1155.sol";
import {GoodReceiver} from "./mocks/GoodReceiver.sol";
import {BadReceiver} from "./mocks/BadReceiver.sol";
import {ReentrantReceiver} from "./mocks/ReentrantReceiver.sol";
import {BaseSetup} from "@chimera/BaseSetup.sol";

abstract contract Setup is BaseSetup {
    MockERC1155 internal token;
    GoodReceiver internal goodReceiver;
    BadReceiver internal badReceiver;
    ReentrantReceiver internal reentrantReceiver;

    address[] internal actors;
    uint256[] internal tokenIds;

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

    function setup() internal virtual override {
        token = new MockERC1155("https://example.com/");
        goodReceiver = new GoodReceiver();
        badReceiver = new BadReceiver();
        reentrantReceiver = new ReentrantReceiver(address(token));

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

        // EOA actors
        actors.push(address(0x10001));
        actors.push(address(0x10002));
        actors.push(address(0x10003));
        // Contract actors for callback testing
        actors.push(address(goodReceiver));

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

        // Set approvals for EOA actors
        for (uint256 i = 0; i < 3; i++) {
            vm.prank(actors[i]);
            token.setApprovalForAll(address(this), true);
        }
    }
}

We include three types of contract receivers: one that accepts transfers normally, one that always rejects, and one that attempts reentrancy during the callback. The fuzzer will route transfers to all of these, testing callback handling under different conditions.

Writing properties

// 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 any other known holders
            balanceSum += token.balanceOf(address(this), id);
            balanceSum += token.balanceOf(address(badReceiver), id);
            balanceSum += token.balanceOf(address(reentrantReceiver), id);
            if (balanceSum != ghostTotalSupply[id]) return false;
        }
        return true;
    }

    // P-2: Batch/individual balance consistency
    function invariant_batchBalanceConsistency() public view returns (bool) {
        address[] memory accounts = new address[](actors.length);
        uint256[] memory ids = new uint256[](actors.length);

        // Check each actor against a token ID
        uint256 checkLen = actors.length < tokenIds.length
            ? actors.length
            : tokenIds.length;

        for (uint256 i = 0; i < checkLen; i++) {
            accounts[i] = actors[i];
            ids[i] = tokenIds[i];
        }

        // Resize arrays to checkLen
        assembly {
            mstore(accounts, checkLen)
            mstore(ids, checkLen)
        }

        uint256[] memory batchResult = token.balanceOfBatch(accounts, ids);

        for (uint256 i = 0; i < checkLen; i++) {
            uint256 individual = token.balanceOf(accounts[i], ids[i]);
            if (batchResult[i] != individual) return false;
        }
        return true;
    }

    // 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: 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 sums balances across all known addresses (actors plus contract receivers) and compares against the ghost supply. This catches any token creation or destruction outside the expected paths. P-2 verifies that balanceOfBatch is consistent with individual balanceOf calls, which catches implementations that have separate code paths for batch and individual queries.

Writing target functions

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

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

abstract contract TargetFunctions is Properties {

    // --- Single transfers ---

    function handler_safeTransferFrom(
        uint256 fromSeed,
        uint256 toSeed,
        uint256 idSeed,
        uint256 amount
    ) public {
        address from = actors[fromSeed % 3]; // Only EOA senders
        address to = actors[toSeed % actors.length];
        uint256 id = tokenIds[idSeed % tokenIds.length];

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

        vm.prank(from);
        try token.safeTransferFrom(from, to, id, amount, "") {
            // Success: balances shifted but ghost unchanged (conservation)
        } catch {
            // Revert is acceptable (e.g., bad receiver)
        }
    }

    // --- Batch transfers ---

    function handler_safeBatchTransferFrom(
        uint256 fromSeed,
        uint256 toSeed,
        uint256 amount1,
        uint256 amount2,
        uint256 amount3
    ) public {
        address from = actors[fromSeed % 3];
        address to = actors[toSeed % actors.length];

        uint256[] memory ids = new uint256[](3);
        uint256[] memory amounts = new uint256[](3);

        ids[0] = tokenIds[0];
        ids[1] = tokenIds[1];
        ids[2] = tokenIds[2];

        amounts[0] = bound(amount1, 0, token.balanceOf(from, ids[0]));
        amounts[1] = bound(amount2, 0, token.balanceOf(from, ids[1]));
        amounts[2] = bound(amount3, 0, token.balanceOf(from, ids[2]));

        // Skip if all amounts are zero
        if (amounts[0] == 0 && amounts[1] == 0 && amounts[2] == 0) return;

        vm.prank(from);
        try token.safeBatchTransferFrom(from, to, ids, amounts, "") {
            // Success
        } catch {
            // Revert is acceptable
        }
    }

    // --- Approval management ---

    function handler_setApprovalForAll(
        uint256 actorSeed,
        uint256 operatorSeed,
        bool approved
    ) public {
        address actor = actors[actorSeed % 3];
        address op = actors[operatorSeed % actors.length];

        vm.prank(actor);
        token.setApprovalForAll(op, approved);
    }

    // --- Operator transfer ---

    function handler_operatorTransfer(
        uint256 operatorSeed,
        uint256 fromSeed,
        uint256 idSeed,
        uint256 amount
    ) public {
        address op = actors[operatorSeed % 3];
        address from = actors[fromSeed % 3];
        uint256 id = tokenIds[idSeed % tokenIds.length];

        if (!token.isApprovedForAll(from, op)) return;

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

        vm.prank(op);
        try token.safeTransferFrom(from, address(goodReceiver), id, amount, "") {
            // Operator transfer succeeded
        } catch {}
    }

    // --- Callback rejection test ---

    function handler_transferToBadReceiver(
        uint256 fromSeed,
        uint256 idSeed,
        uint256 amount
    ) public {
        address from = actors[fromSeed % 3];
        uint256 id = tokenIds[idSeed % tokenIds.length];

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

        uint256 senderBefore = token.balanceOf(from, id);
        uint256 receiverBefore = token.balanceOf(address(badReceiver), id);

        vm.prank(from);
        try token.safeTransferFrom(from, address(badReceiver), id, amount, "") {
            // Should not succeed - bad receiver rejects
            assert(false);
        } catch {
            // Verify no state change
            assert(token.balanceOf(from, id) == senderBefore);
            assert(token.balanceOf(address(badReceiver), id) == receiverBefore);
        }
    }

    // --- Reentrancy test ---

    function handler_transferToReentrantReceiver(
        uint256 fromSeed,
        uint256 idSeed,
        uint256 amount
    ) public {
        address from = actors[fromSeed % 3];
        uint256 id = tokenIds[idSeed % tokenIds.length];

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

        uint256 supplyBefore = token.totalSupply(id);

        vm.prank(from);
        try token.safeTransferFrom(from, address(reentrantReceiver), id, amount, "") {
            // If transfer succeeded, supply must be unchanged
            assert(token.totalSupply(id) == supplyBefore);
        } catch {
            // Revert means reentrancy guard worked
        }
    }

    // --- Mint/burn for state exploration ---

    function handler_mint(
        uint256 actorSeed,
        uint256 idSeed,
        uint256 amount
    ) public {
        address to = actors[actorSeed % 3];
        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 % 3];
        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;
    }

    // --- Batch atomicity assertion ---

    function handler_batchAtomicityCheck(
        uint256 fromSeed,
        uint256 amount
    ) public {
        address from = actors[fromSeed % 3];
        amount = bound(amount, 1, 1_000_000e18);

        uint256[] memory ids = new uint256[](2);
        uint256[] memory amounts = new uint256[](2);
        ids[0] = tokenIds[0];
        ids[1] = tokenIds[1];

        // First amount is within balance, second is way over
        amounts[0] = bound(amount, 1, token.balanceOf(from, ids[0]));
        amounts[1] = token.balanceOf(from, ids[1]) + 1; // Guaranteed to fail

        if (token.balanceOf(from, ids[0]) == 0) return;

        uint256 bal0Before = token.balanceOf(from, ids[0]);

        vm.prank(from);
        try token.safeBatchTransferFrom(
            from, address(goodReceiver), ids, amounts, ""
        ) {
            assert(false); // Should not succeed
        } catch {
            // ID 0 balance must not have changed
            assert(token.balanceOf(from, ids[0]) == bal0Before);
        }
    }
}

The handlers cover the full ERC-1155 surface:

  • handler_safeBatchTransferFrom exercises multi-ID transfers with varying amounts per ID.
  • handler_transferToBadReceiver sends to a contract that rejects all callbacks, verifying the transfer reverts and no state changes.
  • handler_transferToReentrantReceiver sends to a contract that re-enters the token during the callback, testing reentrancy protection.
  • handler_batchAtomicityCheck creates a batch where one ID will fail (insufficient balance) and verifies that the other ID's transfer doesn't go through.

Running the campaign

Foundry (quick smoke test)

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

This catches basic violations fast. Callback-related bugs often show up quickly because the receiver contracts are deterministic.

Medusa (broad coverage)

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

Medusa's parallel workers will explore different approval/transfer/batch sequences simultaneously. The callback paths add branching that benefits from broad parallel exploration.

Echidna (deep exploration)

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

Echidna's sequence shrinking is useful for batch bugs. When a batch atomicity assertion fails, the shrunk sequence shows the minimal setup needed to trigger the partial transfer. See our fuzzer comparison for more on when to use each tool.

Interpreting results

Supply conservation failures

If invariant_supplyConservation fails, tokens appeared or disappeared. The most common cause in ERC-1155 is a reentrancy in the callback path. The onERC1155Received() callback fires after balance updates but before the function returns. If the callback re-enters the token contract and triggers another transfer, balances can be double-counted or lost.

Check whether your implementation follows checks-effects-interactions ordering or uses a reentrancy guard. If the handler_transferToReentrantReceiver assertion fails, that confirms the reentrancy vector.

Batch atomicity violations

If handler_batchAtomicityCheck fails, a partial batch transfer occurred. The first ID was transferred but the second ID's failure didn't roll back the first. This usually means the implementation processes transfers in a loop without reverting the entire transaction on individual failures.

Callback rejection bypass

If handler_transferToBadReceiver doesn't revert, the token accepted a transfer to a contract that rejects it. This means either the callback isn't being called, or the return value isn't being checked against the expected selector (IERC1155Receiver.onERC1155Received.selector).

Approval scope issues

If the fuzzer finds a sequence where an unapproved address successfully calls safeTransferFrom(), the approval check has a bug. Look at how isApprovedForAll() is checked in the transfer function and verify the msg.sender == from || isApprovedForAll(from, msg.sender) logic.

ERC-1155 vs ERC-6909 fuzzing

If you're choosing between ERC-1155 and ERC-6909 for a new project, the fuzzing differences are worth considering:

Callbacks. ERC-1155 mandates receiver callbacks on every transfer. ERC-6909 has no mandatory callbacks. This means ERC-1155 fuzzing must include callback-aware receivers (good, bad, reentrant), while ERC-6909 fuzzing can focus purely on accounting and approval logic.

Approval granularity. ERC-1155 has only setApprovalForAll(). ERC-6909 has both per-ID approve() and setOperator(). ERC-6909 properties need to test the interaction between these two systems.

Batch operations. ERC-1155 has native batch transfer and balance query functions with atomicity guarantees. ERC-6909 doesn't. Batch atomicity testing is specific to ERC-1155.

Gas profile. ERC-6909 is lighter per-operation (no callback overhead), which means fuzzing campaigns generate more state transitions per second. This gives ERC-6909 campaigns better coverage per time unit.

Both standards benefit from the same ghost-variable pattern for supply conservation. If your project uses ERC-1155, the callback properties are non-negotiable. If you're on ERC-6909, you trade callback complexity for approval-granularity complexity.

Beyond the basics

Once your core ERC-1155 properties pass, consider these extensions.

  • Test URI consistency. If your contract returns per-ID URIs, add a property that verifies uri(id) returns a non-empty string for all minted IDs.
  • Add custom receiver logic. If your protocol has contracts that receive ERC-1155 tokens and act on them (staking, marketplace listings), include those as receivers in your fuzzing setup.
  • Test mixed fungible/non-fungible. If some IDs are fungible (supply > 1) and others are non-fungible (supply == 1), add properties that verify non-fungible IDs never exceed supply 1.
  • Run in CI. Set up continuous fuzzing to catch regressions on every PR.

ERC-1155's callback requirement makes it one of the more interesting standards to fuzz. The mandatory external calls during transfers create a reentrancy surface that only stateful fuzzing can exercise properly.

If you're building an ERC-1155 token and want a battle-tested invariant suite, request an audit with Recon. We've fuzzed multi-token implementations across gaming, NFTs, and DeFi integrations.

Related Posts

Related Glossary Terms

Catch reentrancy bugs in your multi-token contract