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:
- Per-ID supply conservation. For each token ID, the sum of all balances must equal the total supply. No tokens appear or vanish.
- 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.
- Callback safety. If the receiver is a contract,
onERC1155Received()oronERC1155BatchReceived()must be called. If the receiver doesn't implement the interface or returns the wrong selector, the transfer must revert. - 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. - Batch/individual consistency.
balanceOfBatch()must return the same values as callingbalanceOf()individually for each account/ID pair. - 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_safeBatchTransferFromexercises multi-ID transfers with varying amounts per ID.handler_transferToBadReceiversends to a contract that rejects all callbacks, verifying the transfer reverts and no state changes.handler_transferToReentrantReceiversends to a contract that re-enters the token during the callback, testing reentrancy protection.handler_batchAtomicityCheckcreates 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
How to fuzz ERC 2535 diamond proxies: storage, selectors, and upgrades
Diamond proxies delegate calls to multiple facets, and the bugs live in upgrade sequences and storag...
How to fuzz ERC-4337 account abstraction wallets
ERC-4337 wallets validate their own transactions and manage gas accounting. This guide covers the in...