ERC-1155: The Multi-Token Standard for Ethereum

Before ERC-1155, building a game with both a currency token and unique item NFTs meant deploying two separate contracts. One ERC-20 for the currency. One ERC-721 for the items. Every additional token type meant another deployment, more gas, and more contracts to maintain.

ERC-1155 solves this. One contract manages unlimited token types, handles fungible and non-fungible tokens, and supports batch operations that reduce transaction costs substantially.

This post explains how ERC-1155 works, walks through real implementation patterns, and compares it to ERC-20 and ERC-721 so you know when to reach for each.

The Core Idea

ERC-1155 introduces a two-dimensional balance mapping. Where ERC-20 maps address => uint256, ERC-1155 maps address => tokenId => uint256. That extra dimension is everything.

Each token type gets an ID. The same ID can represent a fungible token (minted in quantity N) or an NFT (minted in quantity 1). The contract does not distinguish between them at the protocol level. You decide what each ID means.

Think of it as a warehouse with numbered bins:

Warehouse (ERC-1155 Contract)
โ”œโ”€โ”€ Bin #0: Gold Coins       (fungible,      quantity: 1,000,000)
โ”œโ”€โ”€ Bin #1: Legendary Sword  (NFT,           quantity: 1)
โ”œโ”€โ”€ Bin #2: Common Shield    (semi-fungible, quantity: 500)
โ””โ”€โ”€ Bin #3: Rare Potion      (semi-fungible, quantity: 50)

One warehouse. One deployment. Any mix of token types.

Basic Implementation

Here is a minimal ERC-1155 contract using OpenZeppelin:

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

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";

contract GameItems is ERC1155 {
    uint256 public constant GOLD   = 0;
    uint256 public constant SWORD  = 1;
    uint256 public constant SHIELD = 2;

    constructor() ERC1155("https://game.example/api/item/{id}.json") {
        _mint(msg.sender, GOLD,   10000, "");
        _mint(msg.sender, SWORD,  1,     "");
        _mint(msg.sender, SHIELD, 100,   "");
    }
}

The URI string contains {id}, which gets replaced by the token ID at query time. Token 0 resolves to item/0.json, token 1 to item/1.json, and so on. One URI template serves all tokens.

Key Functions

Minting

Single mint:

function mint(address to, uint256 id, uint256 amount) public {
    _mint(to, id, amount, "");
}

Batch mint (this is where gas savings become significant):

function mintBatch(
    address to,
    uint256[] memory ids,
    uint256[] memory amounts
) public {
    _mintBatch(to, ids, amounts, "");
}

Five tokens in one transaction instead of five.

Transferring

Single transfer:

function safeTransferFrom(
    address from,
    address to,
    uint256 id,
    uint256 amount,
    bytes memory data
) public

Batch transfer:

function safeBatchTransferFrom(
    address from,
    address to,
    uint256[] memory ids,
    uint256[] memory amounts,
    bytes memory data
) public

Checking Balances

// Single balance
function balanceOf(address account, uint256 id)
    public view returns (uint256)

// Batch balance check
function balanceOfBatch(
    address[] memory accounts,
    uint256[] memory ids
) public view returns (uint256[] memory)

The batch version lets you read multiple balances in a single call.

Approvals

ERC-1155 uses operator-level approval, not per-token approval:

// Alice approves Bob to manage ALL her tokens in this contract
function setApprovalForAll(address operator, bool approved) public

// Check if Bob is approved
function isApprovedForAll(address account, address operator)
    public view returns (bool)

This is all-or-nothing. An approved operator can move any of your tokens in that contract. There is no way to approve access to only one token type. If you need that kind of granularity, ERC-721 per-token approvals are more appropriate.

Be careful which addresses you approve as operators. An approved operator can transfer any token you hold in that contract, not just a specific ID.

Transfer Flow

When a transfer is initiated, the contract runs through several checks before updating state:

User initiates transfer
        |
   Has approval?
   /          \
 yes           no
  |             |
Check balance  Revert
  |
Enough tokens?
  /         \
yes          no
 |            |
Update       Revert
balances
 |
Is receiver a contract?
  /         \
 yes          no
  |            |
Call receiver  Done
  |
Receiver accepts?
  /         \
 yes          no
  |            |
 Done        Revert

The last step matters. If the recipient is a contract, it must implement the receiver interface. Without it, the transfer reverts.

Receiving Tokens in a Contract

If your contract needs to receive ERC-1155 tokens, implement IERC1155Receiver:

import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";

contract ItemVault is IERC1155Receiver {
    function onERC1155Received(
        address operator,
        address from,
        uint256 id,
        uint256 value,
        bytes calldata data
    ) external returns (bytes4) {
        // Handle single token receipt
        return this.onERC1155Received.selector;
    }

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

    function supportsInterface(bytes4 interfaceId)
        external pure returns (bool) {
        return interfaceId == type(IERC1155Receiver).interfaceId;
    }
}

Returning the correct selector signals to the sending contract that the receiver accepted the tokens intentionally. If you skip this interface, any safeTransfer to your contract will revert.

A Real Example: RPG Crafting System

Here is a more complete contract that lets you create token types on the fly and implement a crafting mechanic:

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

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";

contract RPGItems is ERC1155 {
    uint256 private _currentTokenID = 0;

    mapping(uint256 => uint256) public tokenSupply;
    mapping(uint256 => bool)    public isNFT;

    constructor() ERC1155("https://rpg.game/items/{id}") {}

    function createToken(uint256 supply, bool nft)
        external returns (uint256)
    {
        uint256 id = _currentTokenID;
        _currentTokenID++;

        isNFT[id]       = nft;
        tokenSupply[id] = supply;

        _mint(msg.sender, id, supply, "");
        return id;
    }

    // Burn a set of materials and mint the crafted result
    function craft(uint256[] memory materials, uint256 result) external {
        uint256[] memory amounts = _ones(materials.length);
        _burnBatch(msg.sender, materials, amounts);
        _mint(msg.sender, result, 1, "");
    }

    function _ones(uint256 length)
        private pure returns (uint256[] memory)
    {
        uint256[] memory amounts = new uint256[](length);
        for (uint256 i = 0; i < length; i++) {
            amounts[i] = 1;
        }
        return amounts;
    }
}

createToken registers a new token type and mints the full supply to the caller. Pass supply = 1 for an NFT, or any higher number for a fungible or semi-fungible token. craft burns a list of input materials and mints the output item. One transaction covers both sides of the operation.

Metadata Structure

The JSON metadata for each token follows this structure:

{
  "name": "Fire Sword",
  "description": "A legendary blade forged in dragon fire",
  "image": "https://game.example/images/fire-sword.png",
  "properties": {
    "damage": 150,
    "element": "fire",
    "rarity": "legendary"
  }
}

The contract URI template handles routing. You host one JSON file per token ID at the path your URI template describes.

Gas Savings in Practice

Transferring five different token types to one address:

With separate ERC-20 / ERC-721 contracts:
  Transaction 1: Transfer token A  (21,000 base gas + execution)
  Transaction 2: Transfer token B  (21,000 base gas + execution)
  Transaction 3: Transfer token C  (21,000 base gas + execution)
  Transaction 4: Transfer token D  (21,000 base gas + execution)
  Transaction 5: Transfer token E  (21,000 base gas + execution)
  Total base gas: ~105,000

With ERC-1155 safeBatchTransferFrom:
  Transaction 1: Transfer all 5   (21,000 base gas + batch execution)
  Total base gas: ~21,000

The batch execution cost is higher than a single transfer, but the five separate base fees disappear. At scale, or when gas prices are elevated, this difference matters.

Minting is where the savings are even more dramatic. Minting 100 different token types with ERC-20 or ERC-721 requires 100 deployments plus 100 mint transactions. With ERC-1155, you deploy once and call mintBatch once.

Comparing the Standards

FeatureERC-20ERC-721ERC-1155
Token types per contract11Unlimited
FungibilityYesNoBoth
Batch transfersNoNoYes
Gas efficiencyMediumMediumHigh
Per-token metadataNoYesYes
Approval granularityAmountPer tokenOperator
Best forCurrencyUnique assetsMixed assets

When to use ERC-20

Use ERC-20 when you need one fungible token and maximum compatibility with DeFi protocols. Lending platforms, AMMs, and governance systems all expect ERC-20. Using ERC-1155 here adds complexity without benefit.

When to use ERC-721

Use ERC-721 when every token must be unique and you want per-token approval control. Art platforms, domain name registries, and collectibles benefit from ERC-721's tighter ownership semantics and broad marketplace support.

When to use ERC-1155

Use ERC-1155 when you need multiple token types in one contract, especially when those types include both fungible and non-fungible tokens. Gaming inventories, digital item systems, and any application doing batch operations are the main use cases.

The Tradeoffs

ERC-1155 is flexible, but flexibility has a cost.

The operator approval model is coarser than ERC-721. You cannot approve an address to move only your swords but not your gold. Any operator gets full access to everything you hold in that contract. If approval granularity matters, this is a real limitation.

The balance mapping is two-dimensional. Tools, indexers, and wallets that were built for ERC-20 or ERC-721 do not automatically support ERC-1155. Marketplace support has improved significantly, but it is still not as universal as ERC-721 for NFTs.

There is also an argument for simplicity. A contract that manages one token type is easier to audit than one that manages hundreds. For a simple payment token, deploying ERC-1155 is unnecessary complexity.

Conclusion

ERC-1155 is the right standard when a single contract needs to manage multiple token types. It reduces deployment cost, cuts gas on batch operations, and handles fungible, non-fungible, and semi-fungible tokens without separate contracts for each.

For single-purpose tokens, ERC-20 and ERC-721 remain the cleaner choice. But for gaming, digital item systems, or any application mixing token types, ERC-1155 is the practical option.