ERC-1155 Token Development (Multi-token)
ERC-721 is good for unique assets, ERC-20 for fungible. ERC-1155 appeared because real applications often need both types simultaneously. In a gaming project: gold and resources are fungible tokens, characters are NFTs, potions are semi-fungible (100 units of one type). Before ERC-1155, you deployed multiple contracts. EIP-1155 solves this in one contract with significantly lower gas overhead.
Key differences from ERC-20 and ERC-721
Batch operations — the main advantage. Instead of N transactions to transfer N different tokens:
// ERC-721: N transactions
for (uint i = 0; i < tokenIds.length; i++) {
nft.transferFrom(from, to, tokenIds[i]); // N*gas
}
// ERC-1155: one transaction
erc1155.safeBatchTransferFrom(from, to, ids, amounts, data); // ~gas
Gas savings on batch transfer: 40–60% compared to equivalent separate transactions.
Single balance registry:
// Balance of specific token for specific address
mapping(uint256 => mapping(address => uint256)) private _balances;
// Check balance
function balanceOf(address account, uint256 id) public view returns (uint256) {
return _balances[id][account];
}
Semi-fungible tokens — tokens with unique ID but quantity > 1. Limited edition of 1000 copies of a poster (all identical but limited), game items with identical properties. Implementation depends on agreement on how to interpret ID.
ID scheme design
Uint256 for ID provides huge space. Standard schemes:
Simple sequential — tokens with ID 1, 2, 3... Works for simple cases. No built-in semantics.
Bit-packed ID — different uint256 parts encode different properties:
// Example: upper 128 bits = type, lower 128 = instance ID
uint256 constant TYPE_MASK = uint256(type(uint128).max) << 128;
uint256 constant NF_INDEX_MASK = type(uint128).max;
function getTokenType(uint256 id) internal pure returns (uint256) {
return id & TYPE_MASK;
}
function isNonFungible(uint256 id) internal pure returns (bool) {
return id & TYPE_MASK == id; // instance ID = 0 means base type
}
Hierarchical scheme for gaming:
bits [255:240] = category (weapons, armor, resources, consumables)
bits [239:224] = rarity (common, uncommon, rare, epic, legendary)
bits [223:128] = item_type (sword, shield, potion...)
bits [127:0] = instance (0 for fungible, >0 for NFT)
This allows efficient filtering: tokenId & CATEGORY_MASK == WEAPONS_CATEGORY.
Implementation: what matters during development
Standard base — OpenZeppelin
OpenZeppelin ERC1155 is well-tested and covers the standard. Don't reinvent:
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Burnable.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155URIStorage.sol";
contract GameItems is ERC1155, ERC1155Burnable, ERC1155Supply, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
// Per-token URI for unique metadata for each type
mapping(uint256 => string) private _tokenURIs;
constructor() ERC1155("") {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
}
function mint(
address to,
uint256 id,
uint256 amount,
bytes memory data
) external onlyRole(MINTER_ROLE) {
_mint(to, id, amount, data);
}
function mintBatch(
address to,
uint256[] memory ids,
uint256[] memory amounts,
bytes memory data
) external onlyRole(MINTER_ROLE) {
_mintBatch(to, ids, amounts, data);
}
}
Callback security: onERC1155Received
ERC-1155 requires checking that receiving contract implements IERC1155Receiver. If contract doesn't implement interface — safeTransferFrom reverts, tokens don't get lost. This is important difference from ERC-20, where tokens can be sent to contract without support and lost.
Typical mistake: checking supportsInterface instead of calling onERC1155Received. OpenZeppelin does this correctly, but custom implementation easily errs.
Operator approvals
ERC-1155 uses setApprovalForAll — approval is given for ALL tokens in contract. No analogue of approve(spender, tokenId, amount) from ERC-20/721. This is convenient for game marketplaces (one approval → marketplace can move all tokens), but creates risk with compromised operator address.
// For contract operators need whitelist
mapping(address => bool) public trustedOperators;
function setApprovalForAll(address operator, bool approved) public override {
if (approved) {
require(trustedOperators[operator], "Operator not trusted");
}
super.setApprovalForAll(operator, approved);
}
Metadata: URI scheme
Standard defines uri(uint256 id) should return URI with {id} placeholder:
https://api.example.com/tokens/{id}.json
{id} is replaced with hex-encoded lowercase ID value padded with zeros to 64 characters.
For NFT inside ERC-1155 you need per-token URI. OpenZeppelin ERC1155URIStorage supports this:
// Set URI for specific token
function setTokenURI(uint256 tokenId, string memory tokenURI)
external onlyRole(MINTER_ROLE) {
_setURI(tokenId, tokenURI);
}
On-chain metadata — for simple tokens (game currencies, items with basic attributes) you can store basic properties on-chain, generate JSON in uri():
function uri(uint256 id) public view override returns (string memory) {
ItemDefinition memory item = itemDefinitions[id];
return string(abi.encodePacked(
'data:application/json;base64,',
Base64.encode(bytes(abi.encodePacked(
'{"name":"', item.name, '","description":"', item.description,
'","attributes":[{"trait_type":"rarity","value":"', item.rarity, '"}]}'
)))
));
}
Marketplace integration
OpenSea / Blur support ERC-1155. For correct display:
- For NFTs (quantity=1): standard JSON metadata with image, attributes
- For fungible (quantity>1):
decimalsin metadata (OpenSea uses for amount display) -
contractURI()— collection metadata (name, description, image, royalties)
Royalties — EIP-2981 for royalty information:
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract GameItems is ERC1155, ERC2981 {
constructor() ERC1155("") {
// 5% royalty on all tokens
_setDefaultRoyalty(treasury, 500); // 500 = 5% (basis points)
}
// Per-token royalty for special tokens
function setTokenRoyalty(uint256 tokenId, address receiver, uint96 feeNumerator)
external onlyRole(DEFAULT_ADMIN_ROLE) {
_setTokenRoyalty(tokenId, receiver, feeNumerator);
}
}
Typical mistakes
Supply tracking — ERC-1155 base doesn't track totalSupply. If you need cap on mintable amount — use ERC1155Supply and add checks in mint functions.
Burn and totalSupply — after burn totalSupply should decrease. ERC1155Supply does this automatically, custom implementations often forget.
Reentrancy through onERC1155Received — _mint calls onERC1155Received on recipient. If recipient is malicious contract, it can call mint again inside callback. ReentrancyGuard is mandatory for mint functions with external logic.







