一、引言:什么是 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) {
return
interfaceId == 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 个代币ID
if (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错误。
实现细节:
-
首先检查两个输入数组的长度是否一致,若不一致则回滚交易并抛出错误。<

