【技术详解】 OpenZeppelin ERC1155:Solidity 多代币标准实现原理(附完整 Solidity 源码)
一、引言:什么是 ERC1155?
在区块链世界中,代币标准定义了数字资产如何在智能合约中被创建、管理和交互。ERC1155 是 Ethereum Request for Comments 1155 的缩写,是由 Enjin 团队提出并已成为以太坊社区标准的代币规范。它是一种多代币标准,允许在一个智能合约中管理无限数量的代币类型,既可以是同质化代币(Fungible Tokens,如游戏金币),也可以是非同质化代币(Non-Fungible Tokens,如独特的游戏道具),甚至是两者的混合。
与 ERC20(仅支持同质化代币)和 ERC721(仅支持非同质化代币)相比,ERC1155 的最大优势在于高效性和灵活性。它特别适合需要管理大量相似但又可能不同的代币类型的应用场景,比如:
-
游戏行业:一个游戏中可能有数百或数千种道具,比如武器、防具、药水等,每种道具有不同的ID和数量,但同一种道具之间是可互换的(同质化),而不同ID的道具则代表不同的物品(非同质化)。
-
数字收藏品平台:可以同时管理限量版艺术品和批量生产的数字商品。
-
供应链与票据系统:管理不同类型的凭证、许可或资产。
标准 | 用途 | 特点 |
---|---|---|
ERC20 | 同质化代币,比如 USDT、ETH 等 | 所有代币都一模一样,不可区分,比如 1 USDT = 1 USDT |
ERC721 | 非同质化代币(NFT),比如 CryptoPunks、BAYC | 每个代币都是独一无二的,有独立 ID 和元数据 |
ERC1155 | 多代币混合标准,同时支持同质化和非同质化 | 一个合约可以管理 成千上万个代币ID,每个 ID 可以有任意数量,效率高、批量操作强,广泛用于游戏道具、数字资产集合等 |
二、合约概览与基础信息
1. 开源许可证声明
// SPDX-License-Identifier: MIT
// 声明该代码使用的开源许可证是 MIT,允许自由使用、修改和分发
这一行声明了该代码使用的开源许可证是 MIT。MIT 许可证非常宽松,允许开发者自由使用、修改和分发代码,甚至用于商业用途,只需保留原始版权声明即可。这对于开源项目来说极为常见,也是企业友好型许可证。
2. Solidity 版本指定
pragma solidity ^0.8.20;
// 指定该智能合约使用 Solidity 0.8.20 或更高但兼容的版本编译
此语句指定了该智能合约必须使用 Solidity 0.8.20 或更高但兼容的版本进行编译。Solidity 是以太坊智能合约的开发语言,每个版本都会引入新特性、优化和安全性改进。指定版本有助于确保合约在不同环境下的一致性,避免因编译器版本差异导致意外行为。
3. 导入依赖
// 导入各种接口和工具库
import {IERC1155} from "./IERC1155.sol"; // ERC1155 标准接口,定义基础功能
import {IERC1155MetadataURI} from "./extensions/IERC1155MetadataURI.sol"; // 扩展接口,支持通过URI查询元数据
import {ERC1155Utils} from "./utils/ERC1155Utils.sol"; // 工具库,用于处理接收者校验等逻辑
import {Context} from "../../utils/Context.sol"; // 提供 msg.sender 等上下文信息
import {IERC165, ERC165} from "../../utils/introspection/ERC165.sol"; // ERC165 接口检测标准
import {Arrays} from "../../utils/Arrays.sol"; // 数组操作的工具库
import {IERC1155Errors} from "../../interfaces/draft-IERC6093.sol"; // 定义错误类型
这一系列 import
语句导入了 ERC1155 实现所需的各种接口和工具库,包括:
-
IERC1155:ERC1155 标准的基础接口,定义了核心功能如转账、余额查询等。
-
IERC1155MetadataURI:扩展接口,支持通过 URI 获取每个代币的元数据(如图片、描述等)。
-
ERC1155Utils:工具库,用于处理接收者校验等逻辑,确保代币不会被发送到无法处理的合约。
-
Context:提供如
msg.sender
等上下文信息的基础合约。 -
IERC165 和 ERC165:接口检测标准,允许外部工具识别合约实现了哪些功能。
-
Arrays:数组操作的工具库,简化数组处理。
-
IERC1155Errors:定义了 ERC1155 相关的错误类型,用于更精确的错误处理。
这些依赖共同构成了 ERC1155 实现的基础,确保了合约的功能完整性、安全性和可扩展性。
三、合约定义与核心概念
1. 合约声明
/*** @dev 这是一个 ERC1155 标准的通用实现,支持同时管理多种代币(同质化和非同质化的混合)。* 比如:一个游戏里有多种道具,每种道具有不同ID和数量,就可以用 ERC1155 来管理。* 官方文档:https://eips.ethereum.org/EIPS/eip-1155* 原始实现参考:Enjin 团队*/
abstract contract ERC1155 is Context, ERC165, IERC1155, IERC1155MetadataURI, IERC1155Errors {// 使用工具库提供的数组便捷方法using Arrays for uint256[];using Arrays for address[];
ERC1155
被定义为一个 抽象合约(abstract contract),这意味着它不能直接部署,而是需要被其他合约继承并实现其抽象部分。它继承了多个基础合约和接口:
-
Context:提供如
msg.sender
等上下文信息。 -
ERC165:支持接口检测标准,允许外部工具识别合约实现了哪些功能。
-
IERC1155 和 IERC1155MetadataURI:定义了 ERC1155 标准的核心功能和元数据相关功能。
-
IERC1155Errors:定义了该标准相关的错误类型,用于更精确的错误处理。
通过继承这些基础合约和接口,ERC1155
实现了标准化、模块化和可扩展的设计,确保了与其他工具和合约的互操作性。
2. 核心数据结构
a. 代币余额映射
// 核心数据结构1:记录每个代币ID下,每个地址有多少余额// 结构:_balances[代币ID][用户地址] = 余额数量mapping(uint256 id => mapping(address account => uint256)) private _balances;
这是 ERC1155 的核心数据结构之一,用于记录每个代币 ID 下,每个地址的余额。
-
结构:
_balances[代币ID][用户地址] = 余额数量
-
作用:通过二维映射,快速查询和更新任意代币 ID 下任意用户的持有数量。
-
示例:
_balances[1]["0x123..."] = 100
表示地址"0x123..."
持有代币 ID 为 1 的 100 个单位。
这种设计使得合约能够高效管理大量不同类型的代币,同时为每个用户维护独立的余额记录。
b. 操作者授权映射
// 核心数据结构2:记录每个用户授权了哪些地址可以操作他的代币// 结构:_operatorApprovals[用户地址][被授权地址] = 是否授权(true/false)mapping(address account => mapping(address operator => bool)) private _operatorApprovals;
这是另一个核心数据结构,用于记录每个用户授权了哪些地址可以操作他的代币。
-
结构:
_operatorApprovals[用户地址][被授权地址] = 是否授权(true/false)
-
作用:允许用户(owner)授权其他地址(operator)代表其管理所有代币,无需每次交易都单独授权。
-
示例:
_operatorApprovals["0x123..."]["0x456..."] = true
表示地址"0x123..."
授权了地址"0x456..."
可以管理其所有代币。
这种批量授权机制极大地提升了交互效率,特别是在需要频繁进行代币管理的场景中,如游戏道具交易、资产管理平台等。
c. 通用元数据 URI
// 通用元数据URI,可通过字符串替换机制为不同代币ID生成具体URI// 比如:https://example.com/assets/{id}.json,客户端会自动把 {id} 替换成真实代币ID//// 说明:// - 这是一个字符串变量,用于存储 ERC1155 代币的“通用元数据链接模板”// - 该模板通常包含一个占位符 {id},代表代币的唯一编号// - 当用户或前端(比如钱包、市场)想查看某个代币的元数据时(比如图片、名称、属性等),// 它们会根据这个模板,把 {id} 替换为具体的代币ID,然后请求对应的元数据文件// - 例如:代币ID=123,模板是 "https://example.com/assets/{id}.json",// 那么实际请求的元数据链接就是 "https://example.com/assets/123.json"// - 这种设计非常常用,可以极大简化元数据的管理和访问//// 注意:这个变量 _uri 是 private(私有的),只能在本合约内部访问,外部无法直接读取// 但通常会有一个 public 的 uri(uint256 id) 函数来返回它string private _uri;
这是一个私有状态变量,用于存储 ERC1155 代币的通用元数据 URI 模板。
-
作用:提供一个统一的 URI 模板,通常包含一个占位符
{id}
,客户端(如钱包、市场、前端 DApp)在获取某个代币的元数据时,会将{id}
替换为具体的代币 ID,从而获取该代币的元数据文件(如图片、名称、属性等)。 -
示例:
_uri = "https://example.com/assets/{id}.json"
,当查询代币 ID 为 123 的元数据时,实际请求的链接将是"https://example.com/assets/123.json"
。
注意:这个变量是私有的,只能在本合约内部访问,但通常会通过一个公共的 uri(uint256 id)
函数来返回它,允许外部查询。
四、合约功能详解
1. 构造函数:初始化元数据 URI
/*** @dev 构造函数,在部署合约时设置统一的元数据URI模板* * 作用:这是在部署 ERC1155 合约时**自动调用的构造函数**,它的作用是:* - 在合约一部署的时候,就初始化好一个通用的元数据 URI 模板,比如:* "https://example.com/assets/{id}.json"* - 这个模板后续会被用于告诉客户端(如钱包、市场、前端)如何获取每个代币的元数据* * 为什么需要这个?* - ERC1155 标准支持“一个合约管理多个代币”,比如 1000 种道具,每种都有独立的元数据* - 但如果为每个代币都单独存一个 URI,会很麻烦* - 所以通常采用“一个模板 + 动态替换 {id}”的方式,极大简化管理和访问* * @param uri_ 元数据的基础URI模板,比如:* "https://example.com/assets/{id}.json"* 或者 "ipfs://QmXXX/{id}/metadata.json"* 它通常包含一个 {id} 占位符,客户端会将其替换为真实的代币ID*/constructor(string memory uri_) {// 调用内部函数 _setURI(uri_),将传入的 uri_ 字符串保存到本合约的私有状态变量 _uri 中_setURI(uri_);}
-
作用:在合约部署时调用,用于初始化通用的元数据 URI 模板。
-
参数:
uri_
,例如"https://example.com/assets/{id}.json"
,通常包含{id}
占位符,用于动态替换为具体代币 ID。 -
实现:调用内部函数
_setURI(uri_)
,将传入的 URI 模板保存到私有状态变量_uri
中。
为什么需要这个?
在 ERC1155 标准中,一个合约可以管理成百上千种不同的代币。如果为每个代币单独存储一个 URI,不仅繁琐而且低效。通过采用一个统一的 URI 模板 + 动态替换 {id} 的方式,可以极大简化元数据的管理和访问,提升系统的可扩展性和维护性。
2. ERC165 接口检测:支持哪些标准?
// ========== ERC165 接口检测 ==========/*** @dev 检查本合约是否支持某个接口,ERC1155 和 ERC1155MetadataURI 是必须支持的* * 作用:这是一个标准的 ERC165 接口检测函数,用于告诉外部调用者(比如钱包、DApp、市场等):* “本合约支持哪些接口?”* * 在以太坊和智能合约生态中,**接口检测是非常重要的机制**,它让外部工具(如 OpenSea、MetaMask、钱包等)* 能够识别这个合约实现了哪些标准功能(比如 ERC1155、ERC721、ERC20 等),从而决定如何与它交互。* * 举个例子:OpenSea 会先调用这个函数,检测你是不是一个 ERC1155 合约,如果是,才会按 NFT 集合的方式展示你的代币。* * @param interfaceId 要检测的接口的唯一标识符(一个 4 字节的 bytes4 值,每个标准接口都有固定的ID)* @return bool 是否支持该接口(true = 支持,false = 不支持)*/function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {returninterfaceId == type(IERC1155).interfaceId || // ✅ 检查是否支持 ERC1155 标准主接口interfaceId == type(IERC1155MetadataURI).interfaceId || // ✅ 检查是否支持 ERC1155MetadataURI 扩展接口(提供元数据URI)super.supportsInterface(interfaceId); // 🔁 其它接口检测交给父类(比如 ERC165)处理}
-
作用:这是一个标准的 ERC165 接口检测函数,用于告诉外部调用者(如钱包、DApp、市场等)本合约支持哪些接口。
-
返回值:
true
表示支持该接口,false
表示不支持。 -
检测的接口:
-
IERC1155:ERC1155 标准的主接口,定义了核心功能。
-
IERC1155MetadataURI:扩展接口,支持通过 URI 获取代币元数据。
-
其他接口:通过
super.supportsInterface(interfaceId)
将其他接口检测交给父类(如 ERC165)处理。
-
为什么重要?
在以太坊生态中,接口检测是外部工具识别合约功能的关键机制。例如,OpenSea 等市场会先调用这个函数,检测你的合约是否实现了 ERC1155 标准,如果是,才会按照 NFT 集合的方式展示你的代币。这使得你的合约能够与各种工具和平台无缝集成,提升互操作性和用户体验。
3. 元数据相关:获取代币的 URI
// ========== 元数据相关 ==========/*** @dev 返回某个代币ID的元数据URI,这里返回的是通用模板,实际使用时客户端要替换 {id}* * 作用:这是一个标准的 ERC1155 可选函数,用于返回某个代币ID(比如 1、2、3...)对应的元数据文件的访问链接(URI)。* 在这个实现中,我们采取了一种常见的、简化的设计:* - 不为每个代币单独设置一个 URI,而是返回一个**统一的 URI 模板字符串**,比如:* "https://example.com/assets/{id}.json"* - 这个模板中包含一个占位符 `{id}`,它代表代币的唯一编号* - 客户端(比如钱包、OpenSea、前端 DApp)在拿到这个 URI 后,会**自动把 {id} 替换成真实的代币ID**,* 从而得到每个代币真正对应的元数据链接,比如:* - 代币ID=1 → "https://example.com/assets/1.json"* - 代币ID=2 → "https://example.com/assets/2.json"* * ⚠️ 注意:这个函数的参数 `id` 虽然传进来了,但在本实现中我们并没有使用它!* 因为我们是返回一个通用模板,而不是为每个 id 单独返回不同的 URI。* 如果你想要为每个代币定制不同的 URI,你需要在这里根据 id 返回不同的字符串。* * @param id 代币的唯一标识符(比如 1、2、3...)。在这个实现中不会用到,所以用注释标记忽略了它* @return 返回一个字符串,表示该代币的元数据 URI 模板(比如包含 {id} 的链接地址)*/function uri(uint256 /* id */) public view virtual returns (string memory) {return _uri; // 直接返回合约中存储的通用元数据 URI 模板,通常是在构造函数中通过 _setURI(...) 设置的}
-
作用:返回某个代币 ID 的元数据 URI。在这个实现中,返回的是通用模板,而不是为每个代币单独设置的 URI。
-
参数:
id
,代币的唯一标识符。注意:在这个实现中,id
参数并未实际使用,因为返回的是统一模板。 -
返回值:存储在私有变量
_uri
中的通用元数据 URI 模板,如"https://example.com/assets/{id}.json"
。
如何实现个性化 URI?
虽然这个实现返回的是统一模板,但如果需要为每个代币定制不同的 URI,可以在该函数中根据 id
参数返回不同的字符串。例如:
function uri(uint256 id) public view virtual override returns (string memory) {return string(abi.encodePacked("https://example.com/assets/", Strings.toString(id), ".json"));
}
这样,每个代币 ID 都会有一个独特的 URI,指向其专属的元数据文件。
4. 余额查询:查看代币持有量
a. 单个余额查询
// ========== 余额查询 ==========/*** @dev 查询某个地址拥有某个代币ID的数量* @param account 用户地址* @param id 代币ID* @return 该用户拥有该代币的数量*/function balanceOf(address account, uint256 id) public view virtual returns (uint256) {return _balances[id][account]; // 直接从映射中读取余额}
-
作用:查询某个地址 (
account
) 拥有某个代币 ID (id
) 的数量。 -
参数:
-
account
:用户地址。 -
id
:代币 ID。
-
-
返回值:该用户在该代币上的余额数量。
实现原理:直接从二维映射 _balances[id][account]
中读取余额值,简单高效。
b. 批量余额查询
/*** @dev 批量查询多个地址的对应代币ID的余额* * 作用:一次性查询多个用户各自拥有的对应代币的余额,避免多次调用 balanceOf,提升效率,尤其在游戏、批量展示用户资产等场景非常有用。* * @param accounts 地址数组,比如 [Alice, Bob, Carol],表示你想查这几个用户的余额* @param ids 代币ID数组,比如 [1, 2, 3],表示你想查每个用户对应代币ID的余额* @return batchBalances 每个地址对应每个代币的余额数组,长度与输入数组一致* * 要求:accounts 和 ids 两个数组的长度必须相等,比如你提供 3 个用户,就得同时提供 3 个代币ID,否则会报错!* * 举个简单例子:* 假设:* - accounts = [addr1, addr2,addr3] (两个用户)* - ids = [101, 101,102] (两种代币ID)* * 那么函数会返回一个长度为 3 的数组 batchBalances ,其中:* - 第 1 个元素是 addr1 拥有的代币 101 的余额* - 第 2 个元素是 addr2 拥有的代币 101 的余额* - 第 3 个元素是 addr3 拥有的代币 102 的余额* 可以理解为:返回数组的第 i 个元素,代表第 i 个用户(accounts[i])拥有的第 i 个代币(ids[i])的余额。* */function balanceOfBatch(address[] memory accounts,uint256[] memory ids) public view virtual returns (uint256[] memory) {// 先检查输入的两个数组长度是否一致,比如你不能传 3 个地址但只传 2 个代币IDif (accounts.length != ids.length) {// 如果不一致,抛出自定义错误,提示调用者两个数组长度不同revert ERC1155InvalidArrayLength(ids.length, accounts.length); // 长度不一致则报错}// 创建一个用于返回的数组,长度和输入一样,比如传了 3 个地址就返回 3 个余额结果uint256[] memory batchBalances = new uint256[](accounts.length);// 循环遍历每一个地址和对应的代币ID,逐个查询余额for (uint256 i = 0; i < accounts.length; ++i) {// 从输入的数组中按索引取出第 i 个地址 和 第 i 个代币ID// 注意:这里用了 unsafeMemoryAccess,是一种高效但不那么安全的取值方式(在数组访问安全可控时使用)// 一般可以理解为:accounts[i] 和 ids[i]// 然后调用基础的 balanceOf 方法,查询这个地址 拥有 这个代币ID 的余额是多少batchBalances[i] = balanceOf(accounts.unsafeMemoryAccess(i), ids.unsafeMemoryAccess(i));}// 最终返回一个数组,里面按顺序放着每个地址-代币ID 对应的余额return batchBalances;}
-
作用:一次性查询多个地址各自拥有的对应代币的余额,提升效率,特别适用于游戏、批量展示用户资产等场景。
-
参数:
-
accounts
:地址数组,如["0x123...", "0x456..."]
。 -
ids
:代币 ID 数组,如[1, 2]
。
-
-
返回值:一个
uint256[]
数组,包含每个地址对应每个代币的余额,顺序与输入数组一致。 -
要求:
accounts
和ids
两个数组的长度必须相等,否则会抛出ERC1155InvalidArrayLength
错误。
实现细节:
-
首先检查两个输入数组的长度是否一致,若不一致则回滚交易并抛出错误。<