ERC-1155 详细学习指南
参考资料:
- RareSkills:ERC-1155 Multi Token Standard,最后更新于 2025-08-20
- Ethereum 官方 EIP:EIP-1155 Multi Token Standard,创建于 2018-06-17
适合读者:已经会基础 Solidity、理解 ERC-20 / ERC-721 的开发者。
学习目标:理解 ERC-1155 为什么出现、它解决了什么问题、核心接口怎么设计、底层数据结构怎么组织、如何区分同质化 / 非同质化资产、转账和接收安全规则是什么、开发时容易踩哪些坑。
1. ERC-1155 是什么?
ERC-1155 的名字叫 Multi Token Standard,也就是“多代币标准”。
简单说:
ERC-1155 允许一个智能合约同时管理多种 token 类型。
这些 token 类型可以是同质化代币、非同质化 NFT、半同质化资产,甚至是你自己定义的其他资产模型。
和 ERC-20、ERC-721 对比:
| 标准 | 一个合约通常表示什么 | 适合什么资产 |
|---|---|---|
| ERC-20 | 一种同质化代币 | USDT、游戏金币、积分 |
| ERC-721 | 一个 NFT 集合 | BAYC、CryptoPunks 这类一枚一枚独立的 NFT |
| ERC-1155 | 多种 token 类型 | 游戏道具、装备、金币、门票、批量 NFT、半同质化资产 |
官方 EIP 的定义是:一个已部署合约可以包含任意组合的 fungible token、non-fungible token 或其他配置,例如 semi-fungible token。
RareSkills 教程也强调:ERC-1155 的关键价值是可以在一个合约里同时创建和管理同质化与非同质化 token,从而减少多合约部署成本。
2. 为什么需要 ERC-1155?
2.1 ERC-20 和 ERC-721 的局限
假设你在做一个链游,里面有这些资产:
- 游戏金币
$GOLD - 铁剑
- 钻石剑
- 帽子
- 鞋子
- 稀有赛车 NFT
- 限量门票 NFT
如果只用 ERC-20 和 ERC-721,你可能会这样设计:
GameGoldToken.sol // ERC-20,游戏金币
IronSwordNFT.sol // ERC-721,铁剑
DiamondSwordNFT.sol // ERC-721,钻石剑
HatNFT.sol // ERC-721,帽子
ShoesNFT.sol // ERC-721,鞋子
RareCarNFT.sol // ERC-721,赛车
TicketNFT.sol // ERC-721,门票
问题很明显:
-
部署成本高
每种资产都部署一个合约,bytecode 重复,gas 成本高。 -
操作分散
每个合约有自己的地址,交易、授权、查询都要分别调用。 -
批量操作不方便
比如用户一次购买 3 把剑、5 个药水、1 张门票,如果跨多个 ERC-20 / ERC-721 合约,就会很麻烦。 -
授权体验差
用户需要对多个合约分别 approve 或 setApprovalForAll。
ERC-1155 的思路是:
GameAssets.sol
├── id = 0 游戏金币
├── id = 1 铁剑
├── id = 2 钻石剑
├── id = 3 帽子
├── id = 4 鞋子
├── id = 1001 稀有赛车 #1
├── id = 1002 稀有赛车 #2
└── id = ...
一个合约里用不同的 id 区分不同资产。
3. ERC-1155 的核心思想:id 代表 token 类型
ERC-1155 里最重要的概念是:
uint256 id
在 ERC-20 里,你查询余额通常是:
balanceOf(owner)
因为整个合约只有一种 token。
在 ERC-721 里,tokenId 通常代表某一个独一无二的 NFT。
在 ERC-1155 里,id 的含义更灵活:
balanceOf(owner, id)
意思是:
查询某个地址拥有某个 token ID 的数量。
例如:
balanceOf(Alice, 0) = 1000
可能表示 Alice 有 1000 个游戏金币。
balanceOf(Alice, 10001) = 1
可能表示 Alice 拥有 ID 为 10001 的某个 NFT。
balanceOf(Alice, 10002) = 0
表示 Alice 没有这个 NFT。
3.1 id 不一定连续
RareSkills 教程特别指出:ERC-1155 的 token ID 不一定要连续,只要唯一即可。
也就是说:
0, 1, 2, 3
可以。
100, 999, 314592, 2^128 + 7
也可以。
标准并没有规定 token ID 应该如何生成,所以 mint 函数不是 ERC-1155 标准接口的一部分。mint 是具体项目自己实现的逻辑。
4. 同质化、非同质化、半同质化怎么用 ERC-1155 表示?
4.1 同质化 token:同一个 ID 铸造多份
同质化 token 的特点是每一份都相同。
比如游戏金币:
id = 0
name = Game Gold
Alice balance = 1000
Bob balance = 500
所有 id = 0 的 token 都是一样的,只是数量不同。
在 ERC-1155 中,同质化 token 就是:
同一个 id,有多个 supply,可以被不同地址持有不同数量。
比如:
_mint(alice, 0, 1000, "");
_mint(bob, 0, 500, "");
这就类似 ERC-20。
但是注意一个差异:
ERC-1155 标准本身没有 ERC-20 那种
decimals()接口。
RareSkills 教程指出 ERC-1155 的余额是整数单位。官方 metadata schema 里虽然有 decimals 字段,但它属于 metadata 展示层,不是 ERC-20 那种强制接口。
所以,如果你要做游戏金币,通常你可以:
- 直接用整数单位,例如 1 个金币就是
1 - 或者自己约定
1 ether表示 1 个单位,但这不是 ERC-1155 标准强制要求
4.2 非同质化 token:每个唯一资产一个 ID,供应量限制为 1
NFT 的特点是每个资产唯一。
在 ERC-721 中:
tokenId = 1
tokenId = 2
tokenId = 3
每个 tokenId 都唯一,且只能有一个 owner。
在 ERC-1155 中,你也可以这样做:
id = 10001,supply = 1
id = 10002,supply = 1
id = 10003,supply = 1
只要你在合约逻辑中保证每个 NFT 类型的总供应量最多为 1,它就可以像 NFT 一样使用。
关键点:
ERC-1155 标准不会自动帮你保证某个 ID 只能铸造 1 个。
这是你自己的 mint 逻辑要限制的。
错误示例:
// 如果这是 NFT id,但你 mint 了 10 个,那它就不是唯一 NFT 了
_mint(alice, 10001, 10, "");
正确示例:
// NFT 类型的 id,只 mint 1 个
_mint(alice, 10001, 1, "");
同时你最好维护 supply:
mapping(uint256 => uint256) public totalSupply;
function mintNFT(address to, uint256 id) external {
require(totalSupply[id] == 0, "already minted");
totalSupply[id] = 1;
_mint(to, id, 1, "");
}
4.3 半同质化 token:同一个 ID 有多份,但又像“同一批资产”
半同质化资产常见于:
- 门票
- 游戏消耗品
- 同一批限量道具
- 可兑换凭证
例如演唱会门票:
id = 20260527001
name = VIP Ticket
total supply = 100
这 100 张票在演出前可能是同质化的,任意一张都一样;演出结束后,它们可能变成收藏品。
ERC-1155 很适合这种模型,因为它不强制你只能做 ERC-20 或 ERC-721,而是把资产语义交给项目自己定义。
5. ERC-1155 的核心接口
官方 ERC-1155 接口要求实现以下核心能力:
balanceOfbalanceOfBatchsetApprovalForAllisApprovedForAllsafeTransferFromsafeBatchTransferFrom- ERC-165 的
supportsInterface
如果支持 metadata 扩展,还需要:
uri(uint256 id)
6. balanceOf:查询某个地址某个 ID 的余额
接口:
function balanceOf(address account, uint256 id) external view returns (uint256);
含义:
查询 account 拥有 id 这种 token 的数量。
示例:
uint256 gold = balanceOf(alice, 0);
uint256 car = balanceOf(alice, carId);
如果:
balanceOf(alice, 0) = 1000
表示 Alice 有 1000 个 id = 0 的资产。
如果:
balanceOf(alice, 340282366920938463463374607431768211463) = 1
可能表示 Alice 拥有某个具体 NFT。
6.1 和 ERC-721 的 balanceOf 不一样
ERC-721:
balanceOf(owner)
返回 owner 拥有多少个 NFT。
ERC-1155:
balanceOf(owner, id)
返回 owner 对某个具体 ID 的余额。
这意味着:
ERC-1155 标准没有直接提供“查询某个地址一共拥有多少种 token ID”的方法。
如果你想知道 Alice 在一个 ERC-1155 合约里到底拥有哪些 ID,需要:
- 项目合约自己维护枚举结构;或
- 前端 / 后端通过事件日志索引;或
- 使用第三方 indexer。
7. balanceOfBatch:批量查询余额
接口:
function balanceOfBatch(
address[] calldata accounts,
uint256[] calldata ids
) external view returns (uint256[] memory);
它不是“查询一个人所有余额”,而是查询多组 (account, id)。
例如:
accounts = [Alice, Alice, Bob]
ids = [0, 1, 0]
返回:
[
balanceOf(Alice, 0),
balanceOf(Alice, 1),
balanceOf(Bob, 0)
]
要求:
accounts.length == ids.length
否则应该 revert。
适用场景:
- 前端一次性展示多个资产余额
- 游戏背包加载多个道具
- 市场合约检查多个资产是否足够
8. 授权模型:setApprovalForAll
ERC-1155 的授权不是针对单个 ID,而是针对整个合约下的所有 ID。
接口:
function setApprovalForAll(address operator, bool approved) external;
查询:
function isApprovedForAll(address account, address operator) external view returns (bool);
含义:
account 是否允许 operator 操作 account 在这个 ERC-1155 合约里的所有 token。
示例:
setApprovalForAll(market, true);
这表示:
允许 market 合约转移我在这个 ERC-1155 合约里的所有资产,包括所有 ID、所有数量。
RareSkills 教程特别提醒,这个权限非常大,类似于:
- ERC-20 给无限额度 approve
- ERC-721 对整个集合 setApprovalForAll
所以开发市场合约或游戏合约时,要特别注意:
- 前端要清楚提示用户授权范围;
- 用户最好只授权可信合约;
- 合约方要避免滥用 operator 权限;
- 如果业务需要更细粒度授权,可以额外设计 scoped approval,但这不属于 ERC-1155 核心标准。
9. 转账接口:ERC-1155 只支持 safe transfer
ERC-1155 没有 ERC-721 那种普通 transferFrom。
它的标准转账接口是:
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;
9.1 safeTransferFrom 的参数
safeTransferFrom(from, to, id, value, data)
| 参数 | 含义 |
|---|---|
from | 资产来源地址 |
to | 接收地址 |
id | token 类型 |
value | 转移数量 |
data | 额外数据,原样传给接收方 hook |
例如:
safeTransferFrom(alice, bob, 0, 100, "");
表示 Alice 给 Bob 转 100 个 id = 0 的资产。
safeTransferFrom(alice, bob, carId, 1, "");
表示 Alice 给 Bob 转 1 个 carId 对应的 NFT。
9.2 转账时必须检查什么?
一个合规实现通常需要检查:
to != address(0)from有足够余额- 调用者是
from本人,或被from授权 - 更新余额
- emit
TransferSingle - 如果
to是合约,调用onERC1155Received - 接收方必须返回正确 magic value,否则 revert
注意顺序:
官方规范要求余额更新和事件发出发生在调用接收方 hook 之前。
这是为了保证如果接收方 hook 中发生重入,链上状态和事件日志在外部调用前已经一致。
10. 批量转账:safeBatchTransferFrom
接口:
function safeBatchTransferFrom(
address from,
address to,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external;
示例:
uint256[] memory ids = new uint256[](3);
uint256[] memory values = new uint256[](3);
ids[0] = 0; values[0] = 100; // 100 个金币
ids[1] = 1; values[1] = 2; // 2 把剑
ids[2] = carId; values[2] = 1; // 1 辆车 NFT
safeBatchTransferFrom(alice, bob, ids, values, "");
要求:
ids.length == values.length
否则 revert。
10.1 为什么批量转账省 gas?
如果不用 batch,你可能要调用 3 次:
safeTransferFrom(alice, bob, 0, 100, "");
safeTransferFrom(alice, bob, 1, 2, "");
safeTransferFrom(alice, bob, carId, 1, "");
每次调用都有固定交易成本、权限检查、事件、hook 检查。
使用 batch 可以把多次操作合并到一次交易中:
safeBatchTransferFrom(alice, bob, ids, values, "");
RareSkills 教程中的 gas 对比示例显示,3 次单独 safeTransferFrom 的 gas 明显高于一次 safeBatchTransferFrom。
11. ERC-1155 接收方机制
ERC-1155 的 safe transfer 机制要求:
- 如果接收方是 EOA,不调用 hook
- 如果接收方是合约,必须实现接收接口
- 如果接收方合约没有实现,或者返回值错误,转账必须 revert
11.1 单个接收 hook
接收合约需要实现:
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external returns (bytes4);
正确返回值:
bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))
也就是:
0xf23a6e61
11.2 批量接收 hook
批量接收合约需要实现:
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external returns (bytes4);
正确返回值:
bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))
也就是:
0xbc197c81
11.3 OpenZeppelin 中通常怎么写?
如果你写的是接收 ERC-1155 的合约,可以继承 OpenZeppelin 的 ERC1155Holder:
import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
contract MyVault is ERC1155Holder {
// 可以安全接收 ERC-1155
}
如果你需要自定义收到 token 后的逻辑,可以实现 IERC1155Receiver 或继承相关 receiver 合约。
12. ERC-165 接口识别
ERC-1155 标准要求合约支持 ERC-165。
核心 ERC-1155 interface id:
0xd9b67a26
metadata 扩展 interface id:
0x0e89341c
receiver interface id:
0x4e2312e0
常见判断:
supportsInterface(0xd9b67a26) == true
表示该合约支持 ERC-1155 主接口。
如果支持 metadata:
supportsInterface(0x0e89341c) == true
12.1 为什么要 ERC-165?
因为别的合约或前端可以通过 supportsInterface 判断:
- 这个合约是不是 ERC-1155?
- 它是否支持 metadata URI?
- 接收方是否支持 ERC-1155 receiver?
这样可以减少盲目调用,提高兼容性。
13. ERC-1155 的核心数据结构详解
RareSkills 教程给出的典型 ERC-1155 存储结构是:
mapping(uint256 id => mapping(address account => uint256 balance)) internal _balances;
mapping(address account => mapping(address operator => bool isApproved)) internal _operatorApprovals;
string private _uri;
我们逐个拆开理解。
14. _balances:余额结构
mapping(uint256 => mapping(address => uint256)) internal _balances;
这是一层嵌套 mapping。
可以理解成一张二维表:
_balances[id][account] = balance
例如:
_balances[0][Alice] = 1000
_balances[0][Bob] = 500
_balances[1][Alice] = 2
_balances[1][Bob] = 0
_balances[carId][Alice] = 1
_balances[carId][Bob] = 0
换成表格:
| token ID | account | balance |
|---|---|---|
| 0 | Alice | 1000 |
| 0 | Bob | 500 |
| 1 | Alice | 2 |
| 1 | Bob | 0 |
| carId | Alice | 1 |
| carId | Bob | 0 |
14.1 为什么外层是 id?
OpenZeppelin 和 RareSkills 示例通常是:
mapping(uint256 id => mapping(address account => uint256 balance)) _balances;
也就是:
先按 token ID 找,再按账户找余额。
原因是 ERC-1155 的操作经常围绕某个 ID:
balanceOf(account, id)
safeTransferFrom(from, to, id, value, data)
当然,理论上你也可以写成:
mapping(address => mapping(uint256 => uint256)) balances;
但是标准不规定内部存储实现,只规定外部接口行为。OpenZeppelin 的结构是常见实现方式。
14.2 转账时怎么改 _balances?
假设 Alice 给 Bob 转 100 个 id = 0:
转账前:
_balances[0][Alice] = 1000
_balances[0][Bob] = 500
转账 100:
转账后:
_balances[0][Alice] = 900
_balances[0][Bob] = 600
伪代码:
require(_balances[id][from] >= value, "insufficient balance");
_balances[id][from] -= value;
_balances[id][to] += value;
NFT 也是一样,只是 value 通常是 1:
转账前:
_balances[carId][Alice] = 1
_balances[carId][Bob] = 0
转账后:
_balances[carId][Alice] = 0
_balances[carId][Bob] = 1
这也是 ERC-1155 的统一设计:
不管是 ERC-20 风格资产,还是 NFT 风格资产,本质上都是某个账户对某个 ID 的余额变化。
15. _operatorApprovals:授权结构
mapping(address => mapping(address => bool)) internal _operatorApprovals;
可以理解成:
_operatorApprovals[owner][operator] = true / false
例如:
_operatorApprovals[Alice][Market] = true
_operatorApprovals[Alice][Game] = false
_operatorApprovals[Bob][Market] = true
表示:
- Market 可以操作 Alice 的所有 ERC-1155 资产
- Game 不可以操作 Alice 的资产
- Market 可以操作 Bob 的所有 ERC-1155 资产
15.1 为什么不是 id => approval?
因为 ERC-1155 的标准授权模型就是 “Approval For All”。
也就是说:
setApprovalForAll(operator, true)
授权的是整个 ERC-1155 合约内该 owner 的所有 token ID。
如果你想要针对某些 ID 单独授权,需要自己扩展,不属于核心标准。
15.2 市场合约中如何使用?
市场合约想转走卖家的 ERC-1155:
require(
IERC1155(nft).isApprovedForAll(seller, address(this)),
"market not approved"
);
IERC1155(nft).safeTransferFrom(
seller,
buyer,
tokenId,
amount,
""
);
注意:
- 如果卖的是同质化资产,
amount可以大于 1 - 如果卖的是 NFT,
amount通常是 1 - 市场合约必须能够接收 ERC-1155,特别是托管模式下
16. _uri:metadata 基础 URI
典型存储:
string private _uri;
OpenZeppelin ERC1155 通常使用一条统一 URI 模板:
constructor() ERC1155("https://token-cdn-domain/{id}.json") {}
然后不同 token ID 替换 {id}。
16.1 {id} 替换规则
官方规范要求:
如果 URI 里有 {id},客户端必须把它替换为:
- 小写 16 进制
- 不带
0x - 不足 64 位前面补 0
例如:
token id = 314592
十六进制 = 0x4cce0
URI 中替换成:
000000000000000000000000000000000000000000000000000000000004cce0
所以:
https://token-cdn-domain/{id}.json
会变成:
https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json
这也是你前面问过 314592 转 16 进制的实际用途之一。
16.2 uri(id) 不能用来判断 token 是否存在
官方规范明确说:
uri函数不能用于检查 token 是否存在。
原因是合约可能对任何 id 都返回一个有效 URI:
function uri(uint256) public view returns (string memory) {
return "https://example.com/{id}.json";
}
即使某个 id 从来没有 mint 过,它也可能返回 URI。
判断是否存在通常要靠:
- totalSupply
- mint 记录
- event 日志
- 项目自定义 exists 函数
17. Metadata JSON 结构
ERC-1155 metadata JSON 常见字段:
{
"name": "Asset Name",
"description": "Lorem ipsum...",
"image": "https://example.com/images/{id}.png",
"properties": {
"rarity": "common",
"level": 3
}
}
官方 schema 中常见字段:
| 字段 | 含义 |
|---|---|
name | 资产名称 |
description | 描述 |
image | 图片 URI |
decimals | 展示数量时的小数位,注意是 metadata 层 |
properties | 自定义属性 |
localization | 多语言 metadata |
17.1 为什么 ERC-1155 没有链上 name() 和 symbol()?
官方 rationale 中解释:
symbol对通用虚拟物品不一定有意义,而且容易冲突name放在链上会造成重复数据和更高成本- metadata JSON 更适合作为资产名称和描述的来源
- metadata 支持本地化更方便
所以 ERC-1155 更倾向于:
链上只存 URI,链下 JSON 负责展示信息。
这和 ERC-721 常见的 name() / symbol() 习惯不同。
18. URI 事件和事件日志
ERC-1155 有三个核心事件:
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 owner,
address indexed operator,
bool approved
);
event URI(
string value,
uint256 indexed id
);
18.1 mint 和 burn 也是 transfer
ERC-1155 里 mint 和 burn 要通过事件表达:
mint:
from = address(0)
to = 接收者
burn:
from = 持有者
to = address(0)
例如 mint:
emit TransferSingle(operator, address(0), to, id, value);
burn:
emit TransferSingle(operator, from, address(0), id, value);
18.2 为什么事件非常重要?
官方标准强调,ERC-1155 的事件日志应该足够让外部系统重建当前余额状态。
也就是说,区块浏览器、交易市场、后端 indexer 可以通过监听:
TransferSingleTransferBatchApprovalForAllURI
来知道:
- 哪些 ID 存在
- 每个 ID 的流通量
- 每个账户的余额变化
- metadata URI 是否变化
RareSkills 教程也指出:ERC-1155 标准本身没有提供“列出所有 token ID”的方法,想知道所有 ID,通常需要解析事件日志。
18.3 如何表示“创建一个 ID,但初始供应量为 0”?
官方规范建议:
emit TransferSingle(operator, address(0), address(0), id, 0);
也就是:
from = 0x0
to = 0x0
value = 0
这可以向外部系统广播:
这个 token ID 存在,但现在没有初始余额。
19. 如何通过事件计算供应量?
官方规范说明,客户端和交易所可以用事件计算某个 token ID 的 circulating supply:
从 address(0) 转出的总量 - 转入 address(0) 的总量
也就是:
mint 总量 - burn 总量
例如:
mint 1000 个 id=0
burn 200 个 id=0
circulating supply = 1000 - 200 = 800
但是在合约开发中,通常建议自己维护:
mapping(uint256 => uint256) public totalSupply;
这样合约内查询更方便。
OpenZeppelin 也提供了相关扩展,例如 ERC1155Supply。
20. 结构化 token ID:把 collectionId 和 itemId 编码进 uint256
RareSkills 教程重点讲了一个很实用的技巧:
把
uint256 tokenId分成高 128 位和低 128 位。
高 128 位表示 collection ID,低 128 位表示 item ID。
结构:
uint256 tokenId
= [ 高 128 位 collectionId ][ 低 128 位 itemId ]
图示:
|-------------------- 256 bits --------------------|
|------ 128 bits ------|------ 128 bits ------|
| collectionId | itemId |
20.1 为什么要这样设计?
假设一个 ERC-1155 合约里有多个 NFT 集合:
collectionId = 1 CoolPhotos
collectionId = 2 RareSkills
collectionId = 3 GameCars
每个集合内部又有不同 item:
RareSkills item 7
GameCars item 7
如果直接用随机 ID,不容易看出某个 token 属于哪个集合。
使用结构化 ID:
tokenId = (collectionId << 128) + itemId
就能从 tokenId 反推出:
collectionId = tokenId >> 128
itemId = uint128(tokenId)
20.2 生成 tokenId
function getTokenId(
uint256 collectionId,
uint256 itemId
) public pure returns (uint256 tokenId) {
tokenId = (collectionId << 128) + itemId;
}
例如:
uint256 collectionId = 2;
uint256 itemId = 7;
uint256 tokenId = (collectionId << 128) + itemId;
结果是:
0x0000000000000000000000000000000200000000000000000000000000000007
含义:
高 128 位:2
低 128 位:7
20.3 解析 tokenId
function decodeTokenId(
uint256 tokenId
) public pure returns (uint256 collectionId, uint256 itemId) {
collectionId = tokenId >> 128;
itemId = uint128(tokenId);
}
解释:
collectionId = tokenId >> 128;
把低 128 位丢掉,只保留高 128 位。
itemId = uint128(tokenId);
强转成 uint128,只保留低 128 位。
20.4 为什么是 128 / 128?
因为 ERC-1155 的 ID 是 uint256,一共有 256 位。
分成两半:
128 位 collectionId
128 位 itemId
足够大:
2^128 = 340282366920938463463374607431768211456
这就是你前面问到的那个大数。
也就是说 collectionId 和 itemId 各自最多都可以表达接近 3.4e38 种可能,完全够用。
20.5 开发中要注意溢出和范围
如果你使用 128 / 128 编码,最好限制参数:
function getTokenId(uint256 collectionId, uint256 itemId)
public
pure
returns (uint256)
{
require(collectionId <= type(uint128).max, "collectionId too large");
require(itemId <= type(uint128).max, "itemId too large");
return (collectionId << 128) | itemId;
}
这里用 | 也可以:
(collectionId << 128) | itemId
因为高 128 位和低 128 位没有重叠。
21. ERC-1155 的 NFT 模型和 ERC-721 的区别
21.1 ERC-721
ERC-721 的核心关系是:
tokenId => owner
例如:
mapping(uint256 => address) private _owners;
一个 tokenId 只能有一个 owner。
21.2 ERC-1155
ERC-1155 的核心关系是:
id => owner => balance
即:
mapping(uint256 => mapping(address => uint256)) private _balances;
这意味着:
- 一个 id 可以有多个 holder
- 每个 holder 可以有不同数量
- 如果你想让它像 NFT,必须限制该 id 的总供应量为 1
21.3 ERC-1155 NFT 没有天然 ownerOf
ERC-721 有:
ownerOf(tokenId)
ERC-1155 没有标准 ownerOf(id)。
因为对于 ERC-1155 来说:
id = 0
Alice balance = 100
Bob balance = 200
这种情况很正常,根本没有唯一 owner。
如果你的某些 ID 是 NFT,并且你想查询 owner,可以自己维护:
mapping(uint256 => address) public ownerOf1155NFT;
但是这不是标准接口,外部市场不一定识别。
22. 一个通俗的游戏资产例子
假设我们设计一个游戏资产合约:
id = 0 游戏金币,fungible
id = 1 药水,fungible
id = (1 << 128) + 0 赛车集合 #0,NFT
id = (1 << 128) + 1 赛车集合 #1,NFT
id = (2 << 128) + 0 皮肤集合 #0,NFT
代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract GameAssets is ERC1155, Ownable {
uint256 public constant GOLD = 0;
uint256 public constant POTION = 1;
uint256 public constant CAR_COLLECTION = 1;
uint256 public constant SKIN_COLLECTION = 2;
mapping(uint256 => uint256) public totalSupply;
constructor()
ERC1155("https://example.com/metadata/{id}.json")
Ownable(msg.sender)
{}
function encodeNFTId(
uint256 collectionId,
uint256 itemId
) public pure returns (uint256) {
require(collectionId <= type(uint128).max, "collectionId too large");
require(itemId <= type(uint128).max, "itemId too large");
return (collectionId << 128) | itemId;
}
function decodeNFTId(
uint256 tokenId
) public pure returns (uint256 collectionId, uint256 itemId) {
collectionId = tokenId >> 128;
itemId = uint128(tokenId);
}
function mintGold(address to, uint256 amount) external onlyOwner {
totalSupply[GOLD] += amount;
_mint(to, GOLD, amount, "");
}
function mintPotion(address to, uint256 amount) external onlyOwner {
totalSupply[POTION] += amount;
_mint(to, POTION, amount, "");
}
function mintCar(address to, uint256 itemId) external onlyOwner {
uint256 tokenId = encodeNFTId(CAR_COLLECTION, itemId);
require(totalSupply[tokenId] == 0, "NFT already minted");
totalSupply[tokenId] = 1;
_mint(to, tokenId, 1, "");
}
function mintSkin(address to, uint256 itemId) external onlyOwner {
uint256 tokenId = encodeNFTId(SKIN_COLLECTION, itemId);
require(totalSupply[tokenId] == 0, "NFT already minted");
totalSupply[tokenId] = 1;
_mint(to, tokenId, 1, "");
}
}
这个合约中:
GOLD和POTION是同质化 tokenCAR_COLLECTION和SKIN_COLLECTION下的 token 是 NFT- NFT 的 tokenId 使用高 128 位 / 低 128 位编码
totalSupply[tokenId] == 0保证 NFT 只 mint 一次
23. ERC-1155 mint / burn 不是标准接口
很多初学者容易误解:
ERC-1155 标准里没有规定 mint 和 burn 的外部接口。
标准只规定了:
- balance 查询
- 授权
- 安全转账
- 批量转账
- 接收方 hook
- 事件规则
- metadata 扩展
至于 mint:
function mint(...)
burn:
function burn(...)
怎么设计,是项目自己的事。
但是只要发生余额变化,就应该符合事件规则:
- mint emit
TransferSingle/TransferBatch,from = address(0) - burn emit
TransferSingle/TransferBatch,to = address(0)
使用 OpenZeppelin 的 _mint、_mintBatch、_burn、_burnBatch 会自动处理这些事件和安全检查。
24. ERC-1155 和市场合约的交互
如果你写 NFT 市场,支持 ERC-1155 时要注意:
24.1 上架时要记录 amount
ERC-721 上架通常记录:
address nft;
uint256 tokenId;
address seller;
uint256 price;
ERC-1155 需要多一个数量:
address token;
uint256 id;
uint256 amount;
address seller;
uint256 price;
因为 ERC-1155 可能是:
- 一个 NFT:
amount = 1 - 多个同质化道具:
amount = 100 - 半同质化门票:
amount = 3
24.2 授权检查
卖家要授权市场:
IERC1155(token).setApprovalForAll(market, true);
市场检查:
require(
IERC1155(token).isApprovedForAll(seller, address(this)),
"not approved"
);
24.3 购买时转账
IERC1155(token).safeTransferFrom(
seller,
buyer,
id,
amount,
""
);
如果市场采用托管模式,市场合约需要实现 ERC-1155 receiver,否则用户转入市场合约会失败。
25. 重入风险:safe transfer 会调用外部合约
RareSkills 教程中提到 ERC-1155 mint 和 transfer 中的重入风险。原因是:
safeTransferFrom
_mint
当接收方是合约时,会调用:
onERC1155Received
或:
onERC1155BatchReceived
这就是一次外部调用。
外部调用意味着接收方合约有机会反过来调用你的合约。
25.1 错误风险示例
假设你写一个购买函数:
function buy(uint256 orderId) external payable {
Order storage order = orders[orderId];
require(order.active, "inactive");
require(msg.value == order.price, "wrong price");
token.safeTransferFrom(
order.seller,
msg.sender,
order.id,
order.amount,
""
);
order.active = false;
}
问题:
safeTransferFrom如果转给合约买家,会调用买家的 hook- 买家 hook 里可能重入
buy(orderId) - 此时
order.active还没变成 false
更安全的写法:
function buy(uint256 orderId) external payable nonReentrant {
Order storage order = orders[orderId];
require(order.active, "inactive");
require(msg.value == order.price, "wrong price");
order.active = false;
token.safeTransferFrom(
order.seller,
msg.sender,
order.id,
order.amount,
""
);
// 后续处理资金
}
核心思想:
遵循 Checks-Effects-Interactions:
先检查,再改状态,最后外部调用。
25.2 OpenZeppelin 的实现已经做了很多保护,但业务逻辑仍然要小心
ERC-1155 标准要求在调用 receiver hook 前更新余额和发事件。
但你的业务合约中仍可能有自己的状态,例如:
- 订单状态
- 拍卖状态
- 用户可提款余额
- mint 限制
- 白名单使用次数
这些业务状态也要在外部调用前处理好。
26. ERC-1155 的优点
26.1 一个合约管理多种资产
适合游戏、票务、会员、积分、批量 NFT 项目。
26.2 批量操作省 gas
safeBatchTransferFrom 可以一次转多个 ID 和数量。
26.3 授权简单
一次 setApprovalForAll 可以授权整个合约下所有资产。
26.4 灵活表达资产类型
一个标准里可以表达:
- ERC-20 风格资产
- ERC-721 风格资产
- 半同质化资产
- 多集合 NFT
26.5 事件日志设计适合索引
规范强调通过事件重建余额状态,适合区块浏览器、市场、游戏后端索引。
27. ERC-1155 的缺点和注意点
27.1 授权粒度大
setApprovalForAll 授权整个合约所有 ID,用户风险较大。
27.2 没有标准枚举接口
标准没有:
totalIds()
tokenByIndex()
tokensOfOwner()
也没有直接列出所有 token ID 的方法。
前端通常需要依赖事件索引。
27.3 NFT 没有标准 ownerOf
如果你把某些 ID 当 NFT 用,外部仍然只能通过:
balanceOf(owner, id)
判断某个地址是否持有。
27.4 metadata 更多依赖链下
name、description、image 等通常在 JSON 里,不在链上接口中。
27.5 复杂度更高
ERC-1155 统一了多种资产模型,但也要求开发者自己设计好:
- token ID 规则
- supply 规则
- metadata 规则
- NFT / FT 的区分逻辑
- 前端展示逻辑
28. ERC-1155 开发时的常见设计方案
28.1 简单游戏资产
id = 0 金币
id = 1 木剑
id = 2 铁剑
id = 3 药水
适合初学项目。
28.2 多 NFT 集合
tokenId = (collectionId << 128) | itemId
适合在一个合约里管理多个 NFT 系列。
28.3 半同质化门票
id = eventId
amount = 门票数量
同一个 eventId 下有多张票。
28.4 市场托管资产
市场合约接收 ERC-1155,需要实现 receiver:
contract Market is ERC1155Holder {
...
}
29. ERC-1155 和 ERC-721 如何选择?
| 场景 | 建议 |
|---|---|
| 只做一个传统 NFT 集合 | ERC-721 更简单,兼容性直观 |
| 一个合约里有大量游戏道具 | ERC-1155 更合适 |
| 同时有金币、装备、NFT | ERC-1155 更合适 |
| 需要批量转账 | ERC-1155 更合适 |
强依赖 ownerOf 和 NFT 枚举 | ERC-721 更方便 |
| 用户需要单独授权某一个 NFT | ERC-721 体验更细粒度 |
| 资产供应量既可能是 1,也可能大于 1 | ERC-1155 更灵活 |
通俗判断:
如果你的资产天然是“一整个 NFT 系列”,用 ERC-721 很舒服。
如果你的资产更像“游戏背包 / 多种物品 / 多种资产集合”,ERC-1155 更合适。
30. ERC-1155 实战检查清单
开发 ERC-1155 合约时,可以按下面检查:
30.1 接口和继承
- 是否继承 OpenZeppelin
ERC1155 - 是否正确支持 ERC-165
- 是否需要
ERC1155Supply - 是否需要
Ownable/AccessControl - 是否需要
Pausable - 是否需要
ReentrancyGuard
30.2 token ID 设计
- ID 是否有清晰规则
- 是否需要区分 fungible / non-fungible
- NFT 类型是否限制 supply 为 1
- 是否需要 collectionId / itemId 编码
- 是否防止 collectionId 和 itemId 超过 128 位
30.3 mint / burn
- mint 权限是否受控
- burn 权限是否合理
- 是否维护 totalSupply
- NFT 是否防止重复 mint
- 是否使用 OpenZeppelin 内部函数自动发事件
30.4 转账和接收
- 合约接收 ERC-1155 时是否实现 receiver
- 业务状态是否在 safe transfer 前更新
- 是否考虑 receiver hook 重入
- 是否需要
nonReentrant
30.5 授权
- 前端是否提示
setApprovalForAll权限很大 - 市场合约是否检查
isApprovedForAll - 是否需要更细粒度授权扩展
30.6 metadata
- URI 是否使用
{id}模板 -
{id}是否按 64 位小写 16 进制补零 - JSON 是否包含 name、description、image
- 是否需要 properties
- 是否需要 localization
- 不要用
uri(id)判断 token 是否存在
30.7 事件和索引
- mint 是否 from = address(0)
- burn 是否 to = address(0)
- batch 操作是否 ids / values 顺序一致
- 是否需要后端监听 TransferSingle / TransferBatch
31. ERC-1155 最小示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract Simple1155 is ERC1155, Ownable {
uint256 public constant GOLD = 0;
uint256 public constant SWORD = 1;
constructor()
ERC1155("https://example.com/metadata/{id}.json")
Ownable(msg.sender)
{}
function mint(
address to,
uint256 id,
uint256 amount
) external onlyOwner {
_mint(to, id, amount, "");
}
function mintBatch(
address to,
uint256[] calldata ids,
uint256[] calldata amounts
) external onlyOwner {
_mintBatch(to, ids, amounts, "");
}
}
部署后:
mint(alice, GOLD, 1000);
mint(alice, SWORD, 1);
Alice 拥有:
id = 0,数量 1000
id = 1,数量 1
32. 一个带结构化 NFT ID 的完整示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MultiCollection1155 is ERC1155, Ownable {
uint256 public constant GOLD = 0;
uint256 public constant CAR_COLLECTION = 1;
uint256 public constant WEAPON_COLLECTION = 2;
mapping(uint256 => uint256) public totalSupply;
constructor()
ERC1155("https://example.com/metadata/{id}.json")
Ownable(msg.sender)
{}
function encodeId(
uint256 collectionId,
uint256 itemId
) public pure returns (uint256) {
require(collectionId <= type(uint128).max, "collectionId too large");
require(itemId <= type(uint128).max, "itemId too large");
return (collectionId << 128) | itemId;
}
function decodeId(
uint256 tokenId
) public pure returns (uint256 collectionId, uint256 itemId) {
collectionId = tokenId >> 128;
itemId = uint128(tokenId);
}
function mintGold(address to, uint256 amount) external onlyOwner {
totalSupply[GOLD] += amount;
_mint(to, GOLD, amount, "");
}
function mintCarNFT(address to, uint256 itemId) external onlyOwner {
uint256 tokenId = encodeId(CAR_COLLECTION, itemId);
require(totalSupply[tokenId] == 0, "already minted");
totalSupply[tokenId] = 1;
_mint(to, tokenId, 1, "");
}
function mintWeaponNFT(address to, uint256 itemId) external onlyOwner {
uint256 tokenId = encodeId(WEAPON_COLLECTION, itemId);
require(totalSupply[tokenId] == 0, "already minted");
totalSupply[tokenId] = 1;
_mint(to, tokenId, 1, "");
}
}
这个例子体现了 ERC-1155 的核心能力:
GOLD是同质化资产CAR_COLLECTION和WEAPON_COLLECTION是 NFT 集合- 一个合约管理多种资产
- 使用结构化 tokenId 区分 collection 和 item
- 使用
totalSupply[tokenId] == 0限制 NFT 只能 mint 一次
33. 学习 ERC-1155 时最重要的几个结论
-
ERC-1155 是多 token 标准,不是单纯 NFT 标准。
-
id是 ERC-1155 的核心,代表某种 token 类型。 -
同质化 token = 同一个 id 铸造多份。
-
非同质化 token = 每个唯一资产一个 id,并限制 supply 为 1。
-
ERC-1155 的余额结构是
id => account => balance。 -
ERC-1155 没有标准
ownerOf,也没有标准枚举所有 ID 的接口。 -
setApprovalForAll权限很大,会授权该合约下所有 ID。 -
ERC-1155 只支持 safe transfer,转给合约时必须走 receiver hook。
-
safe transfer / mint 会调用外部合约,业务层要防重入。
-
metadata URI 的
{id}必须替换为 64 位小写十六进制,不带0x。 -
mint / burn 不是标准接口,但事件规则必须符合标准。
-
结构化 tokenId,例如
(collectionId << 128) | itemId,非常适合多 NFT 集合。
34. 推荐学习顺序
如果你后续要自己实现和测试 ERC-1155,可以按这个顺序:
- 先写一个最小 ERC-1155,能 mint 单个 ID。
- 写
balanceOf测试,理解id => account => balance。 - 写 batch mint / batch transfer 测试。
- 写
setApprovalForAll后由第三方转账的测试。 - 写一个 ERC-1155 接收合约,理解 receiver hook。
- 写一个不能接收 ERC-1155 的普通合约,观察转账失败。
- 增加
totalSupply,限制某些 ID supply 为 1。 - 实现
(collectionId << 128) | itemId编码。 - 写一个简单市场合约支持 ERC-1155 上架和购买。
- 给市场合约加重入测试,理解 safe transfer 的外部调用风险。
35. 参考链接
- RareSkills ERC-1155 教程:https://rareskills.io/post/ERC-1155
- EIP-1155 官方规范:https://eips.ethereum.org/EIPS/eip-1155
- OpenZeppelin ERC1155 文档:https://docs.openzeppelin.com/contracts/api/token/erc1155