ERC-1155 多代币标准 — 程序员完全学习指南
目录
- 概述与背景
- 核心设计思想
- 数据结构详解
- 核心接口详解
- 事件系统
- 接收者钩子机制
- URI 元数据机制
- Token ID 设计模式
- 与 ERC-20/ERC-721 对比
- 安全注意事项
- 实战代码示例
- 常见应用场景
- 总结
1. 概述与背景
什么是 ERC-1155?
ERC-1155 是以太坊上的多代币标准(Multi Token Standard),由 Enjin 团队于 2018 年提出(EIP-1155)。它允许一个智能合约同时管理多种代币类型——包括同质化代币(Fungible Token)、非同质化代币(Non-Fungible Token)和半同质化代币(Semi-Fungible Token)。
为什么需要 ERC-1155?
| 问题 | ERC-20/721 的局限 | ERC-1155 的解决方案 |
|---|---|---|
| 多代币管理 | 每种代币需要部署一个合约 | 一个合约管理所有代币 |
| 批量操作 | 无原生批量转账支持 | 原生支持批量转账/查询 |
| Gas 成本 | N 次转账 = N 笔交易 | N 次转账 = 1 笔交易 |
| 代币类型 | 只能是 FT 或 NFT 其中之一 | 同时支持 FT + NFT + SFT |
2. 核心设计思想
2.1 一合约多代币
传统模式下,一个游戏道具系统可能需要:
- 1 个 ERC-20 合约管理金币
- 1 个 ERC-20 合约管理银币
- N 个 ERC-721 合约管理不同类型的装备
ERC-1155 只需要 1 个合约,通过 id 来区分不同代币类型。
2.2 批量优先(Batch-First)
ERC-1155 的设计哲学是”批量操作是一等公民”:
safeBatchTransferFrom— 一次转多种代币balanceOfBatch— 一次查多个余额
2.3 最小化存储
由于一个合约管理所有代币,链上只需要存储:
- 一个二维映射(地址 × 代币ID → 余额)
- 一个授权映射(地址 × 操作者 → bool)
3. 数据结构详解
这是理解 ERC-1155 的核心。从程序员角度看,ERC-1155 的存储结构极其精简:
3.1 核心存储变量
// 余额映射:tokenId => (账户地址 => 余额)
// 类似 Map<uint256, Map<address, uint256>>
mapping(uint256 => mapping(address => uint256)) private _balances;
// 操作者授权映射:账户地址 => (操作者地址 => 是否授权)
// 类似 Map<address, Map<address, bool>>
mapping(address => mapping(address => bool)) private _operatorApprovals;
// 元数据 URI(可选,通常是一个模板字符串)
string private _uri;
3.2 数据结构图解
┌─────────────────────────────────────────────────────┐
│ ERC-1155 合约 │
├─────────────────────────────────────────────────────┤
│ │
│ _balances (二维映射) │
│ ┌──────────┬────────────────────────────────────┐ │
│ │ Token ID │ 账户余额映射 │ │
│ ├──────────┼────────────────────────────────────┤ │
│ │ id=1 │ {Alice: 1000, Bob: 500} ← 金币 │ │
│ │ (金币) │ (FT) │ │
│ ├──────────┼────────────────────────────────────┤ │
│ │ id=2 │ {Alice: 50, Bob: 200} ← 木材 │ │
│ │ (木材) │ (FT) │ │
│ ├──────────┼────────────────────────────────────┤ │
│ │ id=1001 │ {Alice: 1} ← 传说剑 │ │
│ │ (传说剑) │ (NFT) │ │
│ ├──────────┼────────────────────────────────────┤ │
│ │ id=2001 │ {Bob: 5, Carol: 3} ← 门票 │ │
│ │ (门票) │ (SFT) │ │
│ └──────────┴────────────────────────────────────┘ │
│ │
│ _operatorApprovals (授权映射) │
│ ┌──────────┬──────────────────────────────────┐ │
│ │ 所有者 │ 操作者授权 │ │
│ ├──────────┼──────────────────────────────────┤ │
│ │ Alice │ {Marketplace: true, Bob: false} │ │
│ │ Bob │ {Marketplace: true} │ │
│ └──────────┴──────────────────────────────────┘ │
│ │
│ _uri = "https://game.example/api/item/{id}.json" │
│ │
└─────────────────────────────────────────────────────┘
3.3 与 ERC-20/721 数据结构对比
// ERC-20 的存储(每个代币一个合约)
mapping(address => uint256) private _balances; // 一维
mapping(address => mapping(address => uint256)) private _allowances;
// ERC-721 的存储(每个 NFT 系列一个合约)
mapping(uint256 => address) private _owners; // tokenId => 所有者
mapping(address => uint256) private _balances; // 地址 => 持有数量
mapping(uint256 => address) private _tokenApprovals; // 逐个授权
// ERC-1155 的存储(一个合约管理所有)
mapping(uint256 => mapping(address => uint256)) private _balances; // 二维
mapping(address => mapping(address => bool)) private _operatorApprovals; // 全局授权
关键区别:
- ERC-1155 没有
_owners映射 — 因为同一个id可能有多个持有者 - ERC-1155 的授权是 全局的(
setApprovalForAll),没有逐个代币的approve - ERC-1155 不追踪单个代币的所有者,只追踪余额
3.4 为什么没有 approve 单个代币?
这是 ERC-1155 的一个重要设计决策:
// ❌ ERC-1155 没有这个
function approve(address spender, uint256 id, uint256 amount) external;
// ✅ ERC-1155 只有这个:要么全部授权,要么不授权
function setApprovalForAll(address operator, bool approved) external;
原因: 批量操作场景下,逐个授权的 Gas 成本太高。ERC-1155 选择了更粗粒度但更高效的授权模型。
4. 核心接口详解
4.1 IERC1155 完整接口
interface IERC1155 {
// ============ 事件 ============
event TransferSingle(
address indexed operator,
address indexed from,
address indexed to,
uint256 id,
uint256 value
);
event TransferBatch(
address indexed operator,
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);
// ============ 查询方法 ============
/// @notice 查询某地址持有某代币ID的余额
/// @param account 持有者地址
/// @param id 代币ID
/// @return 余额数量
function balanceOf(address account, uint256 id)
external view returns (uint256);
/// @notice 批量查询余额(数组长度必须一致)
/// @param accounts 地址数组
/// @param ids 代币ID数组
/// @return 对应的余额数组
function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
external view returns (uint256[] memory);
// ============ 授权方法 ============
/// @notice 全局授权/取消授权某操作者
/// @param operator 被授权的操作者地址
/// @param approved true=授权, false=取消
function setApprovalForAll(address operator, bool approved) external;
/// @notice 查询授权状态
function isApprovedForAll(address account, address operator)
external view returns (bool);
// ============ 转账方法 ============
/// @notice 安全转账单个代币
/// @param from 发送者
/// @param to 接收者
/// @param id 代币ID
/// @param amount 数量
/// @param data 附加数据(传给接收者钩子)
function safeTransferFrom(
address from, address to, uint256 id, uint256 amount, bytes calldata data
) external;
/// @notice 安全批量转账
/// @param from 发送者
/// @param to 接收者
/// @param ids 代币ID数组
/// @param amounts 数量数组(与ids一一对应)
/// @param data 附加数据
function safeBatchTransferFrom(
address from, address to, uint256[] calldata ids,
uint256[] calldata amounts, bytes calldata data
) external;
}
4.2 方法执行流程
safeTransferFrom 完整流程:
调用者发起 safeTransferFrom(from, to, id, amount, data)
│
├─ 1. 权限校验
│ msg.sender == from || isApprovedForAll(from, msg.sender)
│ 如果不满足 → revert
│
├─ 2. 余额校验
│ _balances[id][from] >= amount
│ 如果不满足 → revert
│
├─ 3. 更新状态
│ _balances[id][from] -= amount
│ _balances[id][to] += amount
│
├─ 4. 触发事件
│ emit TransferSingle(msg.sender, from, to, id, amount)
│
└─ 5. 接收者检查(如果 to 是合约)
调用 to.onERC1155Received(operator, from, id, amount, data)
返回值必须等于 selector,否则 revert
5. 事件系统
5.1 TransferSingle vs TransferBatch
// 单次转账时触发
event TransferSingle(
address indexed operator, // 实际调用者(可能是授权操作者)
address indexed from, // 代币来源(0地址=铸造)
address indexed to, // 代币去向(0地址=销毁)
uint256 id, // 代币ID
uint256 value // 数量
);
// 批量转账时触发
event TransferBatch(
address indexed operator,
address indexed from,
address indexed to,
uint256[] ids, // 代币ID数组
uint256[] values // 对应数量数组
);
关键规则:
- 铸造(Mint):
from = address(0) - 销毁(Burn):
to = address(0) - 必须在状态变更后触发对应事件,链下索引器依赖此规则
5.2 为什么 operator 很重要?
// 场景:marketplace 合约代替用户转移代币
// operator = marketplace地址
// from = 卖家地址
// to = 买家地址
emit TransferSingle(marketplace, seller, buyer, tokenId, amount);
这让链下系统能区分”谁发起了操作”和”谁的代币被移动”。
6. 接收者钩子机制
6.1 IERC1155Receiver 接口
interface IERC1155Receiver {
/// @notice 接收单个代币时的回调
/// @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))`
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 amount,
bytes calldata data
) external returns (bytes4);
/// @notice 接收批量代币时的回调
/// @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))`
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata amounts,
bytes calldata data
) external returns (bytes4);
}
6.2 为什么需要这个机制?
// 问题场景:如果没有接收者检查
用户误将 ERC-1155 代币转给一个普通合约(如 Uniswap V2 Pair)
→ 合约不知道如何处理这些代币
→ 代币永远锁死在合约里
→ 用户资产丢失
// 解决方案:safe 前缀方法会检查接收者
if (to.isContract()) {
require(
IERC1155Receiver(to).onERC1155Received(...) == selector,
"ERC1155: transfer to non-ERC1155Receiver implementer"
);
}
6.3 EOA vs 合约地址
- EOA(外部账户):不需要实现 Receiver,直接接收
- 合约地址:必须实现
IERC1155Receiver并返回正确的 selector
7. URI 元数据机制
7.1 URI 模板与 ID 替换
// 合约中设置的 URI 模板
_uri = "https://game.example/api/item/{id}.json"
// 当查询 token id = 314592 时
// 客户端应将 {id} 替换为十六进制(64位补零):
// "https://game.example/api/item/000000000000000000000000000000000000000000000000000000000004ccf0.json"
规范要求:
{id}必须替换为小写十六进制,64个字符,无 0x 前缀- 这是客户端的职责,合约只返回模板
7.2 元数据 JSON 结构
{
"name": "黄金之剑",
"description": "一把闪耀着金色光芒的传说武器",
"image": "https://game.example/images/sword_001.png",
"properties": {
"attack": 150,
"durability": 500,
"rarity": "legendary"
}
}
8. Token ID 设计模式
8.1 简单递增模式
uint256 private _currentId;
function mint(address to, uint256 amount) external {
_currentId++;
_mint(to, _currentId, amount, "");
}
8.2 类型分段模式(推荐)
// 用高位表示类型,低位表示具体编号
// 类型 1 (FT-金币): 0x0001_000000000000 ~ 0x0001_FFFFFFFFFFFF
// 类型 2 (NFT-武器): 0x0002_000000000000 ~ 0x0002_FFFFFFFFFFFF
uint256 constant GOLD = (1 << 128) | 0; // 同质化金币
uint256 constant SILVER = (1 << 128) | 1; // 同质化银币
uint256 constant SWORD_BASE = 2 << 128; // NFT 武器系列起始
function mintSword(address to) external {
swordCount++;
uint256 id = SWORD_BASE | swordCount; // 每把剑有唯一ID
_mint(to, id, 1, ""); // 数量为1 = NFT
}
8.3 如何区分 FT 和 NFT?
ERC-1155 标准本身不区分,这是应用层的约定:
| 类型 | 总供应量 | 单用户余额 | 典型示例 |
|---|---|---|---|
| FT(同质化) | 无限/大量 | 可以 > 1 | 金币、材料 |
| NFT(非同质化) | = 1 | 只能是 0 或 1 | 唯一装备 |
| SFT(半同质化) | 有限数量 | 可以 > 1 | 门票、优惠券 |
9. 与 ERC-20/ERC-721 对比
| 维度 | ERC-20 | ERC-721 | ERC-1155 |
|---|---|---|---|
| 代币类型 | 仅同质化 | 仅非同质化 | 两者皆可 |
| 合约数量 | 每种代币1个 | 每个系列1个 | 1个合约管所有 |
| 批量转账 | ❌ 需多次调用 | ❌ 需多次调用 | ✅ 原生支持 |
| 批量查询 | ❌ | ❌ | ✅ balanceOfBatch |
| 授权粒度 | 精确到数量 | 精确到单个NFT | 全局开关 |
| 部署成本 | 高(多合约) | 高(多合约) | 低(单合约) |
| 转账 Gas | 基准 | 基准 | 批量时显著更低 |
| 接收者安全 | ❌ 无检查 | ✅ | ✅ |
| 元数据 | ❌ | ✅ tokenURI | ✅ uri(模板) |
| ERC-165 | ❌ | ✅ | ✅ 必须实现 |
10. 安全注意事项
10.1 重入攻击
// ⚠️ 危险模式:先调用外部合约,再更新状态
function unsafeTransfer(address to, uint256 id, uint256 amount) internal {
// 调用接收者钩子(外部调用!)
IERC1155Receiver(to).onERC1155Received(...);
// 才更新余额 — 重入窗口!
_balances[id][from] -= amount;
_balances[id][to] += amount;
}
// ✅ 安全模式:先更新状态,再调用外部合约(Checks-Effects-Interactions)
function safeTransfer(address to, uint256 id, uint256 amount) internal {
// Checks
require(_balances[id][from] >= amount);
// Effects
_balances[id][from] -= amount;
_balances[id][to] += amount;
// Interactions
emit TransferSingle(...);
if (to.isContract()) {
require(IERC1155Receiver(to).onERC1155Received(...) == selector);
}
}
10.2 数组长度匹配
// 批量操作时必须校验数组长度一致
function safeBatchTransferFrom(..., uint256[] ids, uint256[] amounts, ...) {
require(ids.length == amounts.length, "ERC1155: ids and amounts length mismatch");
// ...
}
10.3 零地址检查
require(to != address(0), "ERC1155: transfer to the zero address");
11. 实战代码示例
11.1 基于 OpenZeppelin 的完整实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract GameItems is ERC1155, Ownable {
// 代币ID常量
uint256 public constant GOLD = 0;
uint256 public constant SILVER = 1;
uint256 public constant WOOD = 2;
uint256 public constant LEGENDARY_SWORD = 3;
uint256 public constant EPIC_SHIELD = 4;
// 跟踪每个ID的总供应量
mapping(uint256 => uint256) private _totalSupply;
constructor()
ERC1155("https://game.example/api/item/{id}.json")
Ownable(msg.sender)
{
// 铸造初始物资
_mint(msg.sender, GOLD, 10000 * 10**18, "");
_mint(msg.sender, SILVER, 50000 * 10**18, "");
_mint(msg.sender, WOOD, 100000, "");
_mint(msg.sender, LEGENDARY_SWORD, 1, ""); // NFT:仅1个
_mint(msg.sender, EPIC_SHIELD, 100, ""); // SFT:限量100个
}
/// @notice 管理员铸造
function mint(
address account,
uint256 id,
uint256 amount,
bytes memory data
) public onlyOwner {
_mint(account, id, amount, data);
_totalSupply[id] += amount;
}
/// @notice 批量铸造
function mintBatch(
address to,
uint256[] memory ids,
uint256[] memory amounts,
bytes memory data
) public onlyOwner {
_mintBatch(to, ids, amounts, data);
for (uint256 i = 0; i < ids.length; i++) {
_totalSupply[ids[i]] += amounts[i];
}
}
/// @notice 用户自行销毁
function burn(address account, uint256 id, uint256 amount) public {
require(
account == msg.sender || isApprovedForAll(account, msg.sender),
"ERC1155: caller is not token owner or approved"
);
_burn(account, id, amount);
_totalSupply[id] -= amount;
}
/// @notice 查询总供应量
function totalSupply(uint256 id) public view returns (uint256) {
return _totalSupply[id];
}
/// @notice 判断某 ID 是否存在(已铸造)
function exists(uint256 id) public view returns (bool) {
return _totalSupply[id] > 0;
}
}
11.2 接收者合约示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import "@openzeppelin/contracts/utils/introspection/ERC165.sol";
contract GameVault is IERC1155Receiver, ERC165 {
// 记录存入的代币
mapping(address => mapping(uint256 => uint256)) public deposits;
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external override returns (bytes4) {
deposits[from][id] += value;
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external override returns (bytes4) {
for (uint256 i = 0; i < ids.length; i++) {
deposits[from][ids[i]] += values[i];
}
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC165, IERC165) returns (bool)
{
return interfaceId == type(IERC1155Receiver).interfaceId
|| super.supportsInterface(interfaceId);
}
}
12. 常见应用场景
12.1 游戏道具系统
Token ID 设计:
├── 0-999: 同质化资源(金币、木材、铁矿...)
├── 1000-9999: 半同质化道具(药水、箭矢、门票...)
└── 10000+: 非同质化装备(每件唯一的传说武器)
12.2 NFT 市场
- 艺术家一次铸造多个版本(SFT 模式)
- 市场合约通过
setApprovalForAll获得转移权限 - 批量上架/下架通过
safeBatchTransferFrom
12.3 DeFi 场景
- LP Token 作为 ERC-1155(每个池子一个 ID)
- 期权/债券到期日作为不同 ID
- 多头寸管理(一个合约管理所有池子)
12.4 门票/凭证系统
- 不同场次/席位用不同 ID
- 同一场次同价位用相同 ID(SFT)
- 批量分发 = 一笔交易
13. 总结
核心要点速记
| # | 要点 | 说明 |
|---|---|---|
| 1 | 一合约多币 | 单合约通过 id 管理无限种代币 |
| 2 | 二维余额映射 | mapping(id => mapping(addr => balance)) |
| 3 | 批量操作原生 | 转账和查询都支持数组参数 |
| 4 | 全局授权 | 只有 setApprovalForAll,无单代币 approve |
| 5 | 接收者检查 | 合约必须实现 Receiver 接口才能接收 |
| 6 | URI 模板 | {id} 替换为 64 位十六进制 |
| 7 | 事件驱动 | 所有状态变更必须有对应事件 |
| 8 | FT/NFT/SFT | 标准不区分,由应用层约定 |
学习路径建议
1. 理解数据结构 → 2. 阅读接口定义 → 3. 跑通 OpenZeppelin 示例
→ 4. 实现自定义铸造逻辑 → 5. 编写接收者合约
→ 6. 部署测试网验证 → 7. 添加 URI 元数据服务