ERC-1155 多代币标准完全学习指南(下)

ERC-1155 详细学习指南下篇,深入讲解同质化/非同质化资产区分、转账安全规则及实战开发注意事项。

· ☕ 29 分钟阅读
ERC-1155 多代币标准完全学习指南(下)

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,门票

问题很明显:

  1. 部署成本高
    每种资产都部署一个合约,bytecode 重复,gas 成本高。

  2. 操作分散
    每个合约有自己的地址,交易、授权、查询都要分别调用。

  3. 批量操作不方便
    比如用户一次购买 3 把剑、5 个药水、1 张门票,如果跨多个 ERC-20 / ERC-721 合约,就会很麻烦。

  4. 授权体验差
    用户需要对多个合约分别 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 接口要求实现以下核心能力:

  • balanceOf
  • balanceOfBatch
  • setApprovalForAll
  • isApprovedForAll
  • safeTransferFrom
  • safeBatchTransferFrom
  • 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,需要:

  1. 项目合约自己维护枚举结构;或
  2. 前端 / 后端通过事件日志索引;或
  3. 使用第三方 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

所以开发市场合约或游戏合约时,要特别注意:

  1. 前端要清楚提示用户授权范围;
  2. 用户最好只授权可信合约;
  3. 合约方要避免滥用 operator 权限;
  4. 如果业务需要更细粒度授权,可以额外设计 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接收地址
idtoken 类型
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 转账时必须检查什么?

一个合规实现通常需要检查:

  1. to != address(0)
  2. from 有足够余额
  3. 调用者是 from 本人,或被 from 授权
  4. 更新余额
  5. emit TransferSingle
  6. 如果 to 是合约,调用 onERC1155Received
  7. 接收方必须返回正确 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 IDaccountbalance
0Alice1000
0Bob500
1Alice2
1Bob0
carIdAlice1
carIdBob0

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 可以通过监听:

  • TransferSingle
  • TransferBatch
  • ApprovalForAll
  • URI

来知道:

  • 哪些 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, "");
    }
}

这个合约中:

  • GOLDPOTION 是同质化 token
  • CAR_COLLECTIONSKIN_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 / TransferBatchfrom = address(0)
  • burn emit TransferSingle / TransferBatchto = 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 更多依赖链下

namedescriptionimage 等通常在 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 更合适
同时有金币、装备、NFTERC-1155 更合适
需要批量转账ERC-1155 更合适
强依赖 ownerOf 和 NFT 枚举ERC-721 更方便
用户需要单独授权某一个 NFTERC-721 体验更细粒度
资产供应量既可能是 1,也可能大于 1ERC-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_COLLECTIONWEAPON_COLLECTION 是 NFT 集合
  • 一个合约管理多种资产
  • 使用结构化 tokenId 区分 collection 和 item
  • 使用 totalSupply[tokenId] == 0 限制 NFT 只能 mint 一次

33. 学习 ERC-1155 时最重要的几个结论

  1. ERC-1155 是多 token 标准,不是单纯 NFT 标准。

  2. id 是 ERC-1155 的核心,代表某种 token 类型。

  3. 同质化 token = 同一个 id 铸造多份。

  4. 非同质化 token = 每个唯一资产一个 id,并限制 supply 为 1。

  5. ERC-1155 的余额结构是 id => account => balance

  6. ERC-1155 没有标准 ownerOf,也没有标准枚举所有 ID 的接口。

  7. setApprovalForAll 权限很大,会授权该合约下所有 ID。

  8. ERC-1155 只支持 safe transfer,转给合约时必须走 receiver hook。

  9. safe transfer / mint 会调用外部合约,业务层要防重入。

  10. metadata URI 的 {id} 必须替换为 64 位小写十六进制,不带 0x

  11. mint / burn 不是标准接口,但事件规则必须符合标准。

  12. 结构化 tokenId,例如 (collectionId << 128) | itemId,非常适合多 NFT 集合。


34. 推荐学习顺序

如果你后续要自己实现和测试 ERC-1155,可以按这个顺序:

  1. 先写一个最小 ERC-1155,能 mint 单个 ID。
  2. balanceOf 测试,理解 id => account => balance
  3. 写 batch mint / batch transfer 测试。
  4. setApprovalForAll 后由第三方转账的测试。
  5. 写一个 ERC-1155 接收合约,理解 receiver hook。
  6. 写一个不能接收 ERC-1155 的普通合约,观察转账失败。
  7. 增加 totalSupply,限制某些 ID supply 为 1。
  8. 实现 (collectionId << 128) | itemId 编码。
  9. 写一个简单市场合约支持 ERC-1155 上架和购买。
  10. 给市场合约加重入测试,理解 safe transfer 的外部调用风险。

35. 参考链接

💬 评论