token id 标识资产类型,amount 表示这种类型的数量。身份不再只由合约地址决定。
Ethereum / ERC standards / Lesson 15.03
一份合约,怎样装下整个资产世界?
金币、药水、门票和唯一的传奇武器,看起来是四种完全不同的资产。 ERC-1155 的关键不是把它们变成同一种东西,而是给同一份账本增加 token id 这一维,再让查询、转移、授权和事件都认识这两个坐标。
多个 id 可以在一次标准调用中一起查询或转移。节省来自共享合约与批处理,不是神奇的固定折扣。
标准提供容器和动作,不替你定义稀缺性。铸造权限、供应上限、元数据与升级权仍要逐项审计。
01 / THE PROBLEM
先想象一个世界,不要先背接口
一款游戏需要货币、消耗品、季票与唯一装备;真正的问题是资产类型太多
如果一个应用只发行一种可互换货币,ERC-20 很自然;如果它只管理一组逐件唯一的收藏品, ERC-721 很自然。可现实系统经常同时需要许多种资产,而且同一种资产还可能有许多份。
每一单位用途相同,Alice 的第 1 枚和第 100 枚无需区分。
可堆叠、可消耗;余额是“还有几瓶”,不是“拥有哪几瓶”。
发行时多份相同,进入赛季后可兑换或销毁,语义随生命周期变化。
应用承诺该 id 永远只发行 1 份,因此这一类型被当作唯一资产。
用 ERC-20 处理前两种资产,通常要为每种代币部署或维护独立合约;用 ERC-721 处理最后一种资产,token id 标识具体物品,但没有“同一 id 有 100 份”的余额概念。 ERC-1155 的提案正是为“一份系统里有很多 token type”设计:一份合约可以管理任意数量的 token id,每个 id 有自己的余额、供应和元数据。[1]
合约地址回答“哪一本总账”,token id 回答“总账里的哪一种资产”,amount 回答“这个账户有多少”。
02 / THE DATA MODEL
ERC-20 是一列余额,ERC-1155 是一张表
从 balance[account] 增加 token id 这一维,得到 balance[id][account]
理解 ERC-1155 最重要的一行,不在转移函数,而在余额模型: 同一个账户对不同 id 分别拥有一个无符号整数余额。
这是一种帮助理解接口的逻辑模型,并非规范强制的 Solidity 存储布局;具体实现可以不同。
这张表解释了接口差异。ERC-20 的 balanceOf(account) 默认大家都在问同一种代币;
ERC-1155 必须再传一个 id。ERC-721 的 ownerOf(tokenId) 直接问某件
唯一物品归谁;ERC-1155 则问“某账户拥有某类型多少份”。[2]
[3]
Interactive model 01
同一个数据结构,为什么能表达三种资产?
切换资产,观察供应与账户分布。分类来自发行策略,不是 id 本身。
Fungible by policy
世界金币 · ID 1
总量很大;每一单位具有相同权利,账户只关心数量,不区分具体哪一枚。
“供应量为 1”为什么还不够证明它永远是 NFT?
当前某个 id 的总供应量是 1,只能证明“现在观察到一份”。如果管理员明天还能对同一 id 再次
mint,它就不再具有永久唯一性。OpenZeppelin 的 ERC1155Supply
也明确提醒:totalSupply(id) == 1 可能意味着 NFT,但并不保证未来不会继续铸造。
[8]
03 / THREE STANDARDS
不要问谁“更高级”,要问你的资产如何被计数
ERC-20、ERC-721、ERC-1155 是不同的接口与数据模型,不是升级替代关系
选择标准不是评奖。稳定币通常需要整个 DeFi 生态熟悉的 ERC-20 接口; 逐件唯一、按单件授权和转移的收藏品常适合 ERC-721;大量异构资产共存、需要原生批处理时, ERC-1155 的二维模型更自然。
分散的资产合约
一份 ERC-1155 合约
| 问题 | ERC-20 | ERC-721 | ERC-1155 |
|---|---|---|---|
| 一份合约主要表达 | 一种同质化代币 | 一组逐件唯一的 token | 任意多个 token type |
| 核心余额问题 | 账户有多少 | 某件归谁;账户有几件 | 账户对某 id 有多少 |
| 身份坐标 | 合约地址 | 合约地址 + token id | 合约地址 + token id |
| 标准原生批量转移 | 没有 | 没有 | safeBatchTransferFrom |
| 第三方操作授权 | 常见按 spender + amount | 单件授权或 operator for all | 同一合约内 operator for all |
| 合约接收保护 | 核心标准没有接收回调 | safe 转移时回调 | 所有标准转移都是 safe 风格 |
04 / THE INTERFACE
核心接口只做四件事:查、转、授权、留证
读取余额,安全转移,设置 operator,发出足以重建余额的事件
ERC-1155 的力量来自一组很小但组合良好的接口。注意:标准定义的是外部可交互行为, 并不规定“谁能铸造”“最大供应是多少”或“能否暂停”。
Read / 查询
balanceOf(account, id)一个坐标balanceOfBatch(accounts, ids)多组坐标isApprovedForAll(owner, operator)授权状态supportsInterface(interfaceId)能力检测
Write / 转移与授权
safeTransferFrom(...)一个 idsafeBatchTransferFrom(...)多个 idsetApprovalForAll(operator, bool)全量 operator
Logs / 状态留证
TransferSingle单类型变化TransferBatch多类型变化ApprovalForAll授权变化URI元数据地址变化
Optional / 扩展
uri(id)metadata URItotalSupply(id)常见库扩展exists(id)常见库扩展burn(...)应用自行暴露
两个 Batch 函数都不是“随便塞一堆东西”
balanceOfBatch(accounts, ids) 按位置配对:结果第 i 项是
balanceOf(accounts[i], ids[i]),不是每个账户与每个 id 的笛卡尔积。
safeBatchTransferFrom(from, to, ids, values, data) 则把
ids[i] 与 values[i] 配对,并且整个批次只有同一个
from 与同一个 to;两组数组长度必须一致。
[1]
interface IERC1155 is IERC165 {
function balanceOf(address account, uint256 id)
external view returns (uint256);
function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
external view returns (uint256[] memory);
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;
function setApprovalForAll(address operator, bool approved) external;
function isApprovedForAll(address account, address operator)
external view returns (bool);
}
ERC-1155 要求通过 ERC-165 报告核心接口支持,即
supportsInterface(0xd9b67a26) == true。这让钱包或合约先询问
“你会不会说 ERC-1155”,再选择正确的交互方式。[4]
05 / NATIVE BATCHING
批量的价值,不是“少写几行”,而是一次原子动作
同一发送者、同一接收者、多个 id 与 amount 配对进入一笔调用
Alice 把金币、药水、季票和遗物一起交给一个市场托管合约。若资产分散在不同标准合约中,
通常需要多次 token-level 转移调用;在同一 ERC-1155 合约里,可以用一次
safeBatchTransferFrom 表达整个包。
Interactive model 02
组一个资产包,看标准原生调用如何变化
这是调用结构模型,不是 Gas 报价器;实际费用取决于实现、状态、接收方与执行环境。
Alice wallet FROM
Market escrow TO
为什么批量通常更高效,但不能写成“永远省 X%”?
多个 token type 共用一份合约,减少重复部署的字节码;批量转移还共享一次交易基础开销、 一次外部入口,并在一组循环中更新余额;OpenZeppelin 等常见实现通常共享一次批量回调。EIP-1155 因此说: 与为多个 token type 发送多笔单独交易相比,批量转移可以降低交易成本。 [1]
但费用不是标准常数。单一 id 转移时,safeTransferFrom 可能比构造长度为 1 的
batch 更合适;自定义存储、重复 id、接收方逻辑、冷/热存储访问、L1 或 L2 的费用结构都会改变结果。
正确说法是“ERC-1155 提供原生聚合机会”,不是“ERC-1155 每次都最便宜”。
06 / SAFE RECEIPT
“安全转移”不是检查人,而是询问合约:你会接吗?
接收者若是合约,必须返回规定的 magic value;否则标准转移回滚
把 token 发给一个不会处理它们的合约,资产可能永久卡住。ERC-1155 因此只有 safe 风格的 标准转移:若接收地址当前有代码,token 合约要调用接收钩子并验证明确的返回值。
检查
调用者是 owner 或已获 operator 授权;余额、地址和数组合法。
更新
扣减 from、增加 to;批次内逐个 id/value 配对处理。
留证
在回调前发出 TransferSingle 或 TransferBatch 事件。
握手
调用 receiver hook;返回 magic value 才接受,否则整笔回滚。
单类型接收函数是 onERC1155Received,接受值为
0xf23a6e61;批量接收函数是 onERC1155BatchReceived,
接受值为 0xbc197c81。接收方也可以主动 revert 表示拒绝。
规范允许一次批量钩子,也允许以多次单项钩子覆盖所有余额变化;这里的互动模型展示前一种常见实现。
[1]
Interactive model 03
改变接收方回答,看整笔交易的结局
余额更新与事件先发生,但只要后续回调拒绝,EVM 会把前面的状态和日志一起回滚。
external returns (bytes4) {
return 0xbc197c81;
}
接收合约明确接受;余额更新与 TransferBatch 日志成为交易结果的一部分。
为什么“先更新、再回调”仍然要警惕重入?
回调是一次外部合约调用。接收合约在回调期间可能再次调用 token 合约或调用你系统里的其他函数。
标准要求相关余额与事件在回调前更新,符合 checks-effects-interactions 的基本方向;但你的自定义逻辑
如果在回调之后才维护关键状态,就可能暴露重入窗口。OpenZeppelin 5.x 文档因此不建议覆写
带 acceptance check 的内部函数,而建议把自定义状态逻辑放在 _update。
[7]
07 / OPERATOR APPROVAL
一次 ApprovalForAll,授权的不是一件,而是一整个柜子
operator 获得同一 ERC-1155 合约内 owner 所有 token id 的操作权
ERC-1155 核心没有 ERC-20 那种“给 spender 100 单位额度”,也没有 ERC-721 的单 token id
授权。标准授权入口是 setApprovalForAll(operator, approved)。
Alice · 0x1155… 合约内
ID 1ID 2ID 7ID 2048Market operator
可以代表 Alice 转移这份合约里的任意 id 与数量,前提是余额足够。 它不会因此获得 Alice 在其他 ERC-1155、ERC-20 或 ERC-721 合约里的资产。
这种设计让一个市场只需获得一次授权,就能处理用户在该集合/世界中的多种资产;
也意味着恶意或被攻破的 operator 可能移动该合约内的全部 token type。授权事件
ApprovalForAll(owner, operator, approved) 必须记录开启或关闭。
[1]
范围不是“整个钱包”
授权键包含 owner、operator 和当前 token 合约地址;其他合约不受影响。
也不是“只卖这一件”
核心标准不提供按 id、数量、截止时间的细粒度 allowance。
先核对 operator 地址
前端域名与签名文案不是权限本身;真正获得权力的是链上 operator 地址。
结束后可设为 false
撤销只阻止未来操作,不能追回已被转走的资产或取消已经执行的交易。
08 / METADATA URI
一个 URI 模板,怎样展开成成千上万份元数据?
{id} 被客户端替换为 64 位、小写、无 0x 前缀的十六进制 token id
多资产合约若为每个 id 重复保存完整 URL,会增加链上存储。ERC-1155 的可选 Metadata URI
扩展允许所有 token type 共享一个包含 {id} 的模板。
Metadata transformer
输入十进制 id,亲手完成标准替换
默认示例使用 EIP-1155 中的 314592,即十六进制 0x4cce0。
允许 0 到 2²⁵⁶−1。这里只做本地格式转换,不读取链上数据。
000000000000000000000000000000000000000000000000000000000004cce0
https://assets.example/000000000000000000000000000000000000000000000000000000000004cce0.json
客户端遇到 URI 或元数据 JSON 值中的 {id} 时,必须用 64 个十六进制字符替换:
只用 0-9a-f,不带 0x,左侧补零。标准元数据 JSON 可以包含
name、description、image,也有可选
decimals 字段用于显示数量的小数位。[1]
链上的 token,为什么画面仍可能被改?
合约通常只返回 URI;名称、描述和图片可能位于普通 HTTPS 服务器或可变的内容入口。 若管理员能改 base URI、服务器能替换 JSON,用户界面看到的内容就可能改变。 选择 IPFS 等内容寻址存储、冻结 URI、把 JSON 作为 data URI 放到链上,能改变信任边界, 但成本和可更新性也不同。OpenZeppelin 官方指南特别提醒:元数据不在链上时,开发者可以改变底层信息。 [6]
核心没有集合级 name 与 symbol
ERC-1155 把人类可读名称交给每个 id 的元数据,避免把金融 ticker 假设强加给所有资产。
有 URI 不证明 token 存在
实现可以对尚未铸造的任意 id 返回同一个模板;标准明确禁止用 uri(id) 判断存在性。
可表达的 URI 变化要留事件
若某 id 的 URI 更新能由事件表达,应发出 URI(value, id);动态算法 URI 可能无法逐项发出。
显示小数是元数据语义
核心没有 decimals() 函数;可选 JSON decimals 告诉客户端如何显示整数余额。
09 / IMPLEMENTATION
标准不会替你守住稀缺性,代码策略必须补上
OpenZeppelin 提供经过广泛使用的基础实现;应用仍要定义角色、供应与唯一性
下面是一份教学骨架:金币与季票可以按策略增发;唯一资产使用独立 id,并用永久
uniqueIdUsed 记录防止销毁后重铸。它展示边界,不是未经审计即可上线的产品。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC1155} from
"@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import {ERC1155Supply} from
"@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import {Ownable} from
"@openzeppelin/contracts/access/Ownable.sol";
contract WorldItems is ERC1155Supply, Ownable {
uint256 public constant GOLD = 1;
uint256 public constant SEASON_PASS = 7;
mapping(uint256 id => bool) private uniqueIdUsed;
constructor()
ERC1155("ipfs://bafy.../{id}.json")
Ownable(msg.sender)
{
_mint(msg.sender, GOLD, 1_000_000, "");
_mint(msg.sender, SEASON_PASS, 500, "");
}
function mintFungible(
address to,
uint256 id,
uint256 amount
) external onlyOwner {
require(id == GOLD || id == SEASON_PASS, "restricted id");
_mint(to, id, amount, "");
}
function mintUnique(address to, uint256 id) external onlyOwner {
require(id >= 1_000_000, "unique id range");
require(!uniqueIdUsed[id], "id permanently used");
uniqueIdUsed[id] = true;
_mint(to, id, 1, "");
}
function burn(uint256 id, uint256 amount) external {
_burn(msg.sender, id, amount);
}
}
逐行看真正的安全假设
-
ERC1155Supply 是扩展,不是核心保证 它跟踪每个 id 的 totalSupply 和 exists;不能从所有 ERC-1155 合约假设这些函数存在。
-
onlyOwner 决定谁能增加供应 部署者私钥、代理升级管理员或角色合约若被攻破,供应规则也会被改写。
-
常量只固定 id,不固定供应 GOLD = 1 只是编号;真正的供应来自每次 _mint 与 _burn 的累计结果。
-
唯一 id 需要永久使用记录 只检查 totalSupply(id) == 0 会允许销毁后再次铸造;永久映射把“曾经使用”也纳入约束。
-
接收钩子仍会执行 _mint 给合约地址时也要通过 receiver acceptance check;接收方可以拒绝或触发回调逻辑。
OpenZeppelin 的基础实现使用
mapping(uint256 id => mapping(address account => uint256))
存余额,并把 operator 授权存为 owner 与 operator 的二维映射。它提供内部
_mint、_mintBatch、_burn、_burnBatch,
由应用决定如何安全暴露。[5]
10 / EVENTS & AUDIT
链上看见一份 ERC-1155,先从事件重建它的历史
标准保证余额变化留痕,但 token id 的发现与丰富查询通常需要索引器
ERC-1155 为减轻合约存储,不要求提供“列出所有 id”或“列出某账户全部资产”的核心函数。 区块浏览器、市场与钱包通常从部署区块开始监听事件,在链下数据库中维护 token id、供应和 URI。
标准要求所有余额变化——包括零值转移、创建与销毁——发出
TransferSingle 或 TransferBatch;事件必须足以让观察者重建当前余额。
若要宣布一个尚无初始余额的新 id,规范建议从零地址向零地址发出 value 为 0 的
TransferSingle。[1]
面对任何 ERC-1155 合约,用这八问完成第一轮审计
-
这是什么链、哪个合约、哪个 id? 名称和图片可能重名;完整身份至少需要 chain id、contract address 与 token id。
-
该 id 的供应不变量是什么? 查当前总量、上限、是否可增发、是否可销毁、销毁后能否重铸,以及约束是否真的在代码里。
-
谁持有 mint、pause、URI 与 upgrade 权限? 标准没有规定这些角色;owner、多签、Timelock、代理管理员都可能改变资产含义。
-
ApprovalForAll 会暴露哪些 id? 枚举 owner 在这份合约中的所有资产,不要只看当前准备交易的那一件。
-
接收钩子是否产生外部调用风险? 检查回调前后状态、重入保护、转发逻辑,以及合约是否有安全的出库路径。
-
批量数组的语义是否被正确验证? ids 与 values 必须同长且按位置配对;自定义逻辑不要错误假设 id 一定唯一或已排序。
-
元数据在哪里,谁能改变? 追踪 base URI、内容哈希、服务器、IPFS pin、链上 data URI 与管理员更新路径。
-
索引器的视图能否与链上余额对账? 用 balanceOf/Batch 抽样复核事件数据库,处理重组、部署起点和代理升级后的事件连续性。
11 / RECAP
把整课压缩成五个坐标
地址定位账本,id 定位类型,amount 定位数量,policy 定义稀缺,events 让外部世界看见变化
Contract
哪一份多资产账本
Token id
账本里的哪一种资产
Amount
账户拥有多少单位
Policy
供应、权限与元数据边界
Events
变化如何被索引与验证
六题自检
1. 在 ERC-1155 中,什么最准确地定位一个资产类型?
选择答案后查看解析。
答案:C。同一条链上,ERC-1155 的资产类型至少由合约地址与 token id 共同定位;跨链比较还要加入 chain id。
2. totalSupply(2048) 当前等于 1,是否足以证明 ID 2048 永远是唯一 NFT?
选择答案后查看解析。
答案:B。核心标准没有 FT/NFT 类型标志;当前供应为 1 也不能证明未来不会再铸造。要审计永久供应规则。
3. balanceOfBatch([Alice, Bob], [1, 7]) 返回哪两项?
选择答案后查看解析。
答案:A。balanceOfBatch 按位置读取 (accounts[i], ids[i]);safeBatchTransferFrom 也按位置配对 ids[i] 与 values[i],且只有一组 from/to。
4. Alice 调用 setApprovalForAll(Market, true) 后,Market 的标准权限范围是什么?
选择答案后查看解析。
答案:C。operator for all 的范围是指定 owner 在这一个 ERC-1155 合约内的全部 token id;不是全钱包,也不是单一 id。
5. 接收合约没有实现 ERC1155Receiver,safeBatchTransferFrom 会怎样?
选择答案后查看解析。
答案:B。标准转移给合约时必须通过 receiver hook;错误返回值、缺少接口或 revert 都会使标准转移回滚。
6. uri(999) 返回一个有效 JSON 地址,能否证明 ID 999 已经存在?
选择答案后查看解析。
答案:A。实现可能对任意 id 返回共享 URI 模板,即使该 id 从未铸造。存在性应看供应扩展、事件历史与具体实现。
12 / TERMS & PRIMARY SOURCES
把术语落回规范原文
本课以 ERC/EIP 与 OpenZeppelin 官方文档、官方实现为事实基线
- token type
- ERC-1155 中由一个 uint256 id 标识的资产类别;同一 id 可有任意数量余额。
- operator
- 经 owner 授权、可以代表其转移同一 ERC-1155 合约内 token 的地址。
- batch
- 在一个标准调用里按位置处理多组 id/value 或 account/id 配对。
- magic value
- receiver hook 必须返回的 bytes4 常量,用明确响应证明接收合约理解并接受 token。
- mint / burn
- 供应增加/减少;通过 Transfer 事件中零地址作为 from/to 表达,但公共入口由实现决定。
- indexer
- 扫描链上事件并建立可搜索数据库的系统;帮助发现 token id,但不替代合约状态验证。
-
01
ERC-1155 · Multi Token Standard
核心规范:函数、事件、safe transfer、receiver、URI 替换、审批、批量能力与事件可追溯性。
-
02
ERC-20 · Token Standard
同质化代币的 balance、transfer、allowance 与事件基线,用于比较一维余额模型。
-
03
ERC-721 · Non-Fungible Token Standard
逐件唯一资产、ownerOf、单件授权与 safe receiver 的规范基线。
-
04
ERC-165 · Standard Interface Detection
supportsInterface 与 interface id 的定义;ERC-1155 依赖它发布接口能力。
-
05
OpenZeppelin Contracts v5.6.1 · ERC1155.sol
锁定发布版本的官方实现,可核对二维余额、operator approval、URI、更新与接收检查的实际代码结构。
-
06
OpenZeppelin Contracts 5.x · ERC-1155 Guide
多代币模型、创建、URI、发送给合约与 ERC1155Holder 的官方教学说明。
-
07
OpenZeppelin Contracts 5.x · ERC1155 API
当前 5.x 函数、扩展、错误、acceptance check 与重入注意事项的 API 参考。
-
08
OpenZeppelin Contracts 5.x · ERC1155Supply
每 id 供应跟踪、exists,以及“供应为 1 不保证未来不会继续铸造”的明确边界。
-
09
ethereum.org · Token Standards
以太坊开发者文档对 ERC-20、ERC-721、ERC-1155 及其组合与批量价值的概览。
The durable model
标准统一动作,策略决定资产。
当你再次看到“ERC-1155 NFT”或“多代币合约”,不要只记住“一个合约里放很多东西”。 先写下合约地址与 id,再读余额、供应与 mint 权限;再看 operator、receiver hook、URI 与事件。只有把接口能力和应用策略分开,你才真正理解这份资产是什么。