JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

ERC-1155: one contract, many tokens

A single contract that holds 1000 identical gold coins AND a one-of-a-kind sword. Meet the multi-token standard that fuses ERC-20 and ERC-721, transfers whole inventories in one atomic batch, and saves a fortune in gas.

A backpack that holds coins and a sword

Picture an RPG inventory: a stack of 1000 gold coins (all perfectly identical), three health potions, and one legendary sword that exists exactly once in the whole game. Two guides ago you learned that interchangeable money is a fungible token modelled by ERC-20; last guide you saw that a unique collectible is an NFT modelled by ERC-721. So how do you put coins and a sword on-chain? The painful pre-2018 answer was: deploy a separate smart contract for every item type — an ERC-20 for gold, another for potions, an ERC-721 for swords. Dozens of contracts to deploy, audit, and index for one game.

ERC-1155, proposed by Enjin's Witek Radomski and co-authors in 2018, fixes this with one idea: let a single contract manage many token ids at once, where each id can be fungible, non-fungible, or anything in between. That is why it is called the multi-token standard. It does not replace ERC-20 or ERC-721 — it generalizes both, and it is now the token standard of choice for games, editions, and bundled assets.

The two-dimensional ledger

Here is the whole trick in one line. ERC-20 keeps `balanceOf(owner)` — a one-dimensional table from address to amount. ERC-721 keeps `ownerOf(tokenId)` — each id maps to exactly one owner. ERC-1155 fuses them into `balanceOf(account, id)`: a two-dimensional ledger keyed by both the token id and the holder. One mapping, `id => (owner => amount)`, expresses every case at once.

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

// The heart of ERC-1155: a balance is indexed by BOTH a token id AND an owner,
// so ONE contract can hold every kind of token a game (or app) needs.
interface IERC1155 {
    // ERC-20 asks balanceOf(owner); ERC-721 asks ownerOf(tokenId).
    // ERC-1155 fuses them: how many of token `id` does `account` hold?
    function balanceOf(address account, uint256 id) external view returns (uint256);
    function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
        external view returns (uint256[] memory);

    // No per-token approve: you grant an operator ALL of your token types at once.
    function setApprovalForAll(address operator, bool approved) external;
    function isApprovedForAll(address account, address operator) external view returns (bool);

    function safeTransferFrom(
        address from, address to, uint256 id, uint256 value, bytes calldata data
    ) external;
    function safeBatchTransferFrom(
        address from, address to,
        uint256[] calldata ids, uint256[] calldata values, bytes calldata data
    ) external;

    event TransferSingle(address indexed op, address indexed from, address indexed to, uint256 id, uint256 value);
    event TransferBatch(address indexed op, address indexed from, address indexed to, uint256[] ids, uint256[] values);
    event ApprovalForAll(address indexed account, address indexed operator, bool approved);
    event URI(string value, uint256 indexed id);
}
The core ERC-1155 interface — note balanceOf takes two arguments.

The token's character is just a consequence of its supply per id. If an id has a large supply of identical units, it behaves as a fungible currency. If an id has total supply exactly 1, it behaves as a non-fungible. And if an id has a small, bounded supply of interchangeable copies, you get a semi-fungible token — think 500 numbered prints of one artwork, or concert tickets that are fungible before the show and become distinct collectibles after. ERC-1155 spans this whole spectrum simply by choosing how much of each id you mint.

One contract, many tokens: minting

Let's actually deploy the backpack. The contract below mints both kinds of token from the same address in its constructor: 1000 fungible GOLD and 1 non-fungible SWORD. By the standard's convention, a mint is emitted as a transfer from the zero address, so explorers and indexers can track issuance just like any other movement.

// A simplified game-item contract. In production you would inherit
// OpenZeppelin's audited ERC1155 instead of hand-rolling the ledger.
contract GameItems {
    // id => (owner => amount): the whole fungible + non-fungible ledger in one map.
    mapping(uint256 => mapping(address => uint256)) public balanceOf;
    mapping(address => mapping(address => bool))   public isApprovedForAll;

    uint256 public constant GOLD  = 1; // fungible: 1000 identical coins
    uint256 public constant SWORD = 2; // non-fungible: total supply of exactly 1

    event TransferSingle(address indexed op, address indexed from, address indexed to, uint256 id, uint256 value);

    constructor() {
        // Mint both token types from the SAME contract to the deployer.
        balanceOf[GOLD][msg.sender]  = 1000;
        balanceOf[SWORD][msg.sender] = 1;
        // ERC-1155 signals a mint as a transfer FROM the zero address.
        emit TransferSingle(msg.sender, address(0), msg.sender, GOLD, 1000);
        emit TransferSingle(msg.sender, address(0), msg.sender, SWORD, 1);
    }
}
Minting a fungible token and an NFT from one ERC-1155 contract.

Notice what just happened. An ERC-721 collection would have needed its own deployment plus per-token `ownerOf` bookkeeping; a plain ERC-20 cannot even express the unique sword. ERC-1155 does both with one deployment and one ledger. That is the headline efficiency win: shared code, one address for wallets and marketplaces to track, and a single `balanceOfBatch` call that reads many balances at once instead of one query per token.

Batch transfers: move a whole inventory at once

The second superpower is `safeBatchTransferFrom`. Selling a loot bundle — sword, 50 gold, 3 potions — to another player is one transaction that moves all three ids together. Crucially it is atomic: either the whole batch succeeds or the whole thing reverts and nothing moves. With ERC-721 you would broadcast three separate transfers, each paying the 21,000-gas base cost and each able to fail independently, leaving a half-completed trade.

// Move many token types in ONE atomic transaction:
// e.g. sell {SWORD x1, GOLD x50, POTION x3} as a single bundle.
function safeBatchTransferFrom(
    address from,
    address to,
    uint256[] calldata ids,
    uint256[] calldata values,
    bytes calldata data
) external {
    require(to != address(0), "transfer to zero address");
    require(ids.length == values.length, "ids/values length mismatch");
    require(from == msg.sender || isApprovedForAll[from][msg.sender], "not authorized");

    for (uint256 i = 0; i < ids.length; ++i) {
        balanceOf[ids[i]][from] -= values[i]; // reverts on underflow in 0.8+
        balanceOf[ids[i]][to]   += values[i];
    }
    emit TransferBatch(msg.sender, from, to, ids, values);

    // If the recipient is a contract, it MUST acknowledge the batch or we revert.
    if (to.code.length > 0) {
        require(
            IERC1155Receiver(to).onERC1155BatchReceived(msg.sender, from, ids, values, data)
                == IERC1155Receiver.onERC1155BatchReceived.selector,
            "unsafe recipient: not an ERC-1155 receiver"
        );
    }
}
A batch transfer loops over ids and values, then checks the recipient.

Be honest about where the savings come from. The big amortized win is the single ~21,000-gas transaction base fee shared across the whole bundle, plus paying the function-dispatch and warm-access overhead once instead of N times. The actual `SSTORE` balance updates still cost per id — five tokens means roughly five pairs of storage writes. So a 50-item batch is dramatically cheaper than 50 transactions, but it is not magically free.

Safety: receiver hooks and operator approvals

Why the `safe` in `safeTransferFrom`? If you send tokens to a contract that has no idea what ERC-1155 is, they would be stranded forever. So the standard requires: whenever the recipient is a contract, it must implement `onERC1155Received` (or `onERC1155BatchReceived`) and return a specific 4-byte magic value. If it returns anything else — or doesn't implement the hook at all — the transfer reverts and your tokens stay safely in your wallet. This is the same receiver-acknowledgement pattern you met in `onERC721Received` last guide.

interface IERC1155Receiver {
    function onERC1155Received(
        address operator, address from, uint256 id, uint256 value, bytes calldata data
    ) external returns (bytes4);

    function onERC1155BatchReceived(
        address operator, address from,
        uint256[] calldata ids, uint256[] calldata values, bytes calldata data
    ) external returns (bytes4);
}

// A vault willing to custody game items must return the exact magic value,
// otherwise every safe(Batch)TransferFrom into it reverts and the tokens stay put.
contract ItemVault is IERC1155Receiver {
    function onERC1155Received(address, address, uint256, uint256, bytes calldata)
        external pure returns (bytes4)
    {
        return this.onERC1155Received.selector;      // 0xf23a6e61
    }
    function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
        external pure returns (bytes4)
    {
        return this.onERC1155BatchReceived.selector; // 0xbc197c81
    }
}
A contract must return the exact selector to accept ERC-1155 tokens.

Now approvals — and a sharp edge. ERC-1155 has no per-token approve. There is no equivalent of ERC-20's allowance or ERC-721's `approve(tokenId)`. The only grant is `setApprovalForAll(operator, true)`, which lets that operator move every token type you hold in this contract. That is exactly what an NFT marketplace needs to list your items, but it is a blanket key to your whole backpack: approve only contracts you trust, and revoke the approval when you are done.

Metadata, trade-offs, and when to reach for 1155

One more efficiency trick lives in the metadata. Instead of storing a separate URI per token, ERC-1155 exposes a single template URI containing the literal substring `{id}`. Clients substitute the lowercase, zero-padded hex id to resolve each token's JSON. One string serves a million ids. (Exactly what that JSON contains, and whether it lives on IPFS or a server, is the whole subject of the next guide.)

// ONE URI template serves EVERY token id (the ERC-1155 metadata extension):
uri = "https://game.example/api/item/{id}.json"

// Clients replace the literal "{id}" with the LOWERCASE hex id,
// zero-padded to 64 characters, with NO 0x prefix.
// For GOLD (id = 1):
//   {id} -> 0000000000000000000000000000000000000000000000000000000000000001
// Resolved metadata URL:
//   https://game.example/api/item/000...0001.json
//
// One template, no per-token storage write -> cheap metadata for millions of ids.
The ERC-1155 metadata URI {id} substitution scheme.

Now the honest trade-offs, because ERC-1155 is not a free lunch. It has no `ownerOf` and no built-in enumeration: for a 1-of-1 token you cannot ask "who owns id 7?" directly — ownership is only expressed as `balanceOf(addr, 7) == 1`, so finding the holder means watching transfer events or running an indexer. Tooling and some marketplaces still treat ERC-721 as the first-class NFT format, so a pure-collectible project may get smoother support from ERC-721. And the operator-only approval model is coarser than per-token approvals.