ERC721Enumerable 扩展是什么? 如何实现以及为什么?

ERC721Enumerable 用来实现特定地址拥有的所有代币

0_bUUmLDiz7MM1xcRy

如果你已经熟悉 EIP-721 标准 ,你可能已经意识到,有趣的是,其设计的一个关键限制是它没有提供一个内置方法来列出特定地址拥有的所有代币,甚至没有列出所有流通中的代币。 这种限制对于需要这些功能的应用程序可能会有问题。例如,一个市场可能需要显示特定艺术家创建的所有 NFT,或者一个游戏可能需要展示玩家拥有的所有独特游戏物品。这就是ERC721Enumerable扩展发挥作用的地方。以下是从 EIP-721 规范中提取的官方描述:

枚举扩展对于 ERC-721 智能合约是可选的。这允许你的合约发布其完整的 NFT 列表并使其可发现。

ERC721Enumerable扩展通过引入额外的数据结构和函数来解决上述限制,从而使一些应用程序能够列出可能有用的代币。没有这个扩展,开发人员将不得不实现自己的机制来跟踪和枚举代币,这将容易出现混乱、错误和安全风险。

基本用法

注意:我们将使用 OpenZeppelin 库的最新版本:`v5.x`。代码中的注释已被删除以提高可读性,因为解释已经在周围的文本中。

要直接使用ERC721Enumerable扩展非常简单,只需导入它并继承到你的ERC721合约中,并覆盖一些函数以能够成功编译合约。

实现 ERC721Metadata 或 ERC721Enumerable 的合约还应该实现 ERC721。ERC-721 实现了接口 ERC-165 的要求。

如果我们想尽快使用它,我们可以直接转到 OpenZeppelin 的向导并通过几次点击获取完整的合约。

例如,以下合约是从 OpenZeppelin 的向导中提取的完全符合的ERC721Enumerable,提供访问控制并允许铸造和销毁。_update()_increaseBalance()_supportsInterface() 是覆盖函数,以使编译器工作,并允许我们部署合约:

// SPDX-License-Identifier: MIT  
pragma solidity ^0.8.20;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";  
import {ERC721Burnable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";  
import {ERC721, ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract MyToken is ERC721, ERC721Enumerable, ERC721Burnable, Ownable {  
    constructor(address initialOwner) ERC721("MyToken", "MTK") Ownable(initialOwner) {}

    function safeMint(address to, uint256 tokenId) public onlyOwner {  
      _safeMint(to, tokenId);  
    }

    function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC721Enumerable) returns (bool) {  
      return super.supportsInterface(interfaceId);  
    }

    function _update(  
      address to,  
      uint256 tokenId,  
      address auth  
    ) internal override(ERC721, ERC721Enumerable) returns (address) {  
      return super._update(to, tokenId, auth);  
    }

     function _increaseBalance(address account, uint128 value) internal override(ERC721, ERC721Enumerable) {  
      super._increaseBalance(account, value);  
    }  
}

如果你想尝试一下,直接从 OpenZeppelin 的向导提取的代码已经准备好部署和使用 在 Remix IDE 中

现在,合约具有以下公开可访问的函数来检索代币的枚举列表:

  • totalSupply() 以获取所有流通中的代币。
  • tokenByIndex(index) 以获取特定索引处的 tokenID
  • tokenOfOwnerByIndex(owner, index) 以获取特定地址的特定索引处的 token ID。

就像这样,你完全符合规范,你的 NFT 是可发现的。很酷,对吧?你可以在 GitHub 中阅读完整的ERC721Enumerable扩展代码库这里

它在底层是如何工作的?

ERC721Enumerable扩展合约的结构主要受到对 ERC721 标准、效率和成本效益的考虑。此扩展添加以下数据结构以提供所提供的功能:

mapping(address owner => mapping(uint256 index => uint256)) private _ownedTokens;  
mapping(uint256 tokenId => uint256) private _ownedTokensIndex;  
uint256 [] private _allTokens;  
mapping(uint256 tokenId => uint256) private _allTokensIndex;

最初,合约基于两个基本数据结构进行组织**_allTokens**数组,其中存储了所有流通中的代币的 ID,以及**_ownedTokens**映射。此映射将地址与另一个映射相关联,将代币索引与它们各自的 ID 关联起来。 _例如(伪代码):

  • _allTokens = [1, 2, 3, 4, 5, 1337]
  • _ownedTokens[Alice][index0] = token1

此外,还有两个映射用于存储代币 ID 的索引,即**_ownedTokensIndex**映射,用于为地址设置和获取给定代币 ID 的索引,以及**_allTokensIndex**映射,用于设置和获取代币 ID 在_allTokens数组中的位置。 _例如(伪代码):

  • _allTokensIndex[token1] = index0
  • _ownedTokensIndex[token1] = index0

所有这些数据结构都是私有的,因此要公开访问它们,我们可以使用前面提到的:totalSupply()tokenByIndex(index)tokenOfOwnerByIndex(owner, index)

使用扩展函数

让我们想象一个非常简单的离线代码片段来使用这些函数并获得我们想要的结果。我们将使用 Javascript 进行操作:

  • 要检索合约中的所有代币,我们可以使用totalSupply()函数获取总流通代币,并循环遍历**tokenByIndex(index)**函数,迭代已知次数。
async function getAllTokens() {  
  const amount = await myToken.totalSupply()  
  let tokens = []  
  for (let i = 0; i < amount; i++) {  
    tokens.push(String(await myToken.tokenByIndex(i)))  
  }  
  return tokens  
}
  • 要从特定地址检索所有代币,我们可以使用balanceOf(owner)函数,并循环遍历**tokenOfOwnerByIndex(index)**,遵循与前一个示例相同的逻辑。
async function getAllTokensFromAddress(address) {  
  const amount = await myToken.balanceOf(address)  
  let tokens = []  
  for (let i = 1; i <= amount; i++) {  
    tokens.push(String(await myToken.tokenOfOwnerByIndex(address, i - 1)))  
  }  
  return tokens  
}

注意: 这里有一种通过读取合约的历史事件并维护全面的链下记录来获取合约中所有代币的替代方法。虽然某些服务提供商,如 Alchemy,提供了这种功能,但需要注意的是,某些 RPC 可能无法很好地处理大量请求,可能会导致依赖于集中实体。

状态更改:更深入的探讨

现在,我们将更详细地解析合约在发生重要事件时的状态水平,例如铸造、转移和销毁。

铸造和转移

如果我们从合约中调用safeMint()函数,铸造机制将与 ERC721 相同但它将通过调用其重写的**_update()**函数来更新 ERC721Enumerable 扩展数据结构。这个_update()函数在转移和销毁代币时也会被调用,它是合约的核心,因为它负责更新这四个新数据结构的状态。

function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) {  
  address previousOwner = super._update(to, tokenId, auth);

  if (previousOwner == address(0)) {  
    _addTokenToAllTokensEnumeration(tokenId);  
  } else if (previousOwner != to) {  
    _removeTokenFromOwnerEnumeration(previousOwner, tokenId);  
  }

  if (to == address(0)) {  
    _removeTokenFromAllTokensEnumeration(tokenId);  
  } else if (previousOwner != to) {  
    _addTokenToOwnerEnumeration(to, tokenId);  
  }

  return previousOwner;  
}

简而言之,该函数检查代币 ID 的previousOwner如果**previousOwner****address(0)**,表示铸造,还应更新_allTokens数组:

function _addTokenToAllTokensEnumeration(uint256 tokenId) private {  
  _allTokensIndex[tokenId] = _allTokens.length;  
  _allTokens.push(tokenId);  
}

相反,如果前一个所有者不是**address(0)**,则表示这是一次转移。在这种情况下,它将简单地更改所有权,通过将其从一个地址中删除并分配给另一个地址。通过执行经典的swap-and-pop操作,从_ownedTokens映射中删除代币。

function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private {  
  uint256 lastTokenIndex = balanceOf(from);  
  uint256 tokenIndex = _ownedTokensIndex[tokenId];  
  if (tokenIndex != lastTokenIndex) {  
    uint256 lastTokenId = _ownedTokens[from][lastTokenIndex];  
    _ownedTokens[from][tokenIndex] = lastTokenId;  
    _ownedTokensIndex[lastTokenId] = tokenIndex;  
  }  
  delete _ownedTokensIndex[tokenId];  
  delete _ownedTokens[from][lastTokenIndex];  
}

在铸造和转移代币的情况下,将调用_addTokenToOwnerEnumeration()函数来更新_ownedTokens映射。

function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private {  
  uint256 length = balanceOf(to) - 1;  
  _ownedTokens[to][length] = tokenId;  
  _ownedTokensIndex[tokenId] = length;  
}

销毁

现在,我们不是创建 NFT,而是想要销毁一个。我们可以调用_burn(),然后将调用_update()函数。这将 _removeTokenFromOwnerEnumeration执行另一个 swap-and-pop 风格的操作,从_ownedTokens 映射中删除代币 ID。但它还将在调用 _removeTokenFromAllTokensEnumeration() 时执行一个 swap-and-pop ,因为我们要删除 NFT:

function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private {  
  uint256 lastTokenIndex = _allTokens.length - 1;  
  uint256 tokenIndex = _allTokensIndex[tokenId];  
  uint256 lastTokenId = _allTokens[lastTokenIndex];  
  _allTokens[tokenIndex] = lastTokenId;  
  _allTokensIndex[lastTokenId] = tokenIndex;  
  delete _allTokensIndex[tokenId];  
  _allTokens.pop();  
}

因此,该函数将删除代币 ID 索引,并重新排列_allTokens数组中的项目,这在创建和转移代币时不会发生。

举个例子吧!

如果这看起来有点令人困惑,请稍等,因为我们将通过一个更直观的示例来展示。查看 此存储库,其中包含我们迄今讨论的所有内容:用于检索数据的链下函数,以及来自 Remix IDE,提取自 OpenZeppelin 的向导 。我们将看到 ERC721Enumerable 内部的数据结构是如何更新的。

请记住(如果你已经阅读了 EIP-721 标准 ),标准中没有规定代币必须按升序顺序铸造,这意味着它们可以使用任意代币 ID 创建(只要不存在)。虽然 OpenZeppelin 的完全符合标准的 ERC721Enumerable 实现允许这样做,但为了演示目的,我们将从 1 开始使用连续的代币 ID。

在测试文件中,我们将只做 3 件事。在每个操作之后,我们将检查并记录合约中更新的数据结构:

1. 铸造 9 个代币: 每个给 Alice、Bob 和 Carla 各 3 个代币。

2. Alice 将她的代币 ID 1 转移给 Carla。

3. Bob 将他的代币 ID 4 销毁。

因此,在安装依赖项并运行npm inpx hardhat test命令后,我们会得到以下输出:

正如你所看到的,当发生铸造或转移时,只更新_ownedTokens _ownedTokensIndex映射。但当代币被销毁时,还会重新组织_allTokens 数组。

由于 Hardhat 的 gas 报告插件 ,测试还将输出交易的平均 gas 使用量。

OpenZeppelin 的设计:有点过分了吗?

三个映射和一个数组仅用于列出代币?感觉可能有更简单的方法。嗯,它可能确实可以以不同的方式实现,减少要维护的状态变量,这可能会使代码更易于阅读。但是,这可能会涉及权衡,特别是在可用性方面,这是 OpenZeppelin 的库无法承受的。其中一个最直观的替代方法之一是创建一个映射到每个地址的代币数组,允许我们列出每个所有的代币。类似于:

mapping (address => uint256[]) private _tokensOwned;

然后是一些函数来检索数组,对吗?也许:

function tokensOfOwner(address owner) public view returns (uint256[] memory) {  
  return _tokensOwned[owner];  
}

function allTokens() public view returns (uint256[] memory) {  
  return _allTokens;  
}

虽然这看起来更直接和更容易理解,因为它直接将所有者映射到他们的代币,我们可以摆脱_allTokensIndex 和_ownedTokensIndex 映射,但只有在 NFT 收藏足够小的情况下才是合理的。但这肯定不是标准应该做出的假设。

当收藏物的大小增加时, Gas成本将开始变得令人难以承受,用于铸造、销毁和转移。想象一下循环遍历成千上万个 NFT 的收藏,比如 MutantApeYachtClub(在撰写本文时约有 20,000 个 NFT),只是为了执行单个转移或销毁代币。每次添加或删除一个元素,整个数组都需要被读取和写入以执行_swap-and-pop_操作。在 EVM 上下文中,这是不可行的

但是,这些是_view函数,不应该花费,对吗? 是的和不是。在 Solidity 中,view函数如果在交易内被调用将会产生 Gas成本,这可能会在铸造、销毁和转移的情况下发生,特别是在不同协议交互之间。因此,如果另一个合约调用我们假设的allTokens()tokensOfOwner(),交易将因为 Gas耗尽而失败。

在这一点上,你可能已经注意到,在阅读_ERC721Enumerable_扩展代码时,合约中没有一个循环。甚至在重新填充_removeTokenFromAllTokensEnumeration()中的数组时也是如此。这一切都是可能的,因为使用声明的映射,所有这些数据都存储在合约本身中,有助于这个扩展的 Gas成本。由于 OpenZeppelin 的库是 Web3 项目中最常用的库,而且它必须遵守 EIP 规范,我认为可以肯定地说,他们的代码看起来像是一种优雅的设计选择,反映了在可用性和代码复杂性之间取得了很好的平衡,在 Gas效率方面做出了一些权衡。

替代方案

有一些已知的替代方案可以解决_ERC721Enumerable_扩展的过高 Gas成本问题,通过将所有权状态更新限制为每批铸造一次,而不是每次铸造 NFT 一次,等等设计。

可能最为人所知的替代方案是 Azuki 团队创建的 ERC721A。简而言之,_ERC721A_允许更便宜的铸造,特别是批量铸造,但转移更昂贵。查看使用 ERC721A 进行铸造时节省一些 Gas的奢华方式

其他替代方案包括 ERC721Psi,受_ERC721A_影响,也解决了昂贵的转移问题,以及来自 Shadow Quest 游戏协议的 ERC721:Shadow,自称为有史以来最节省 Gas的协议,但你无法控制代币 ID,因为它们是自动生成的。

如果你想了解更多,请查看 w1nt3r_eth 的 Twitter 帖子 。最终,你会发现每种替代方案都实际上都有其缺点。

结论

当开发人员需要一种方式来跟踪合约内所有代币或特定帐户拥有的所有代币,而不希望依赖外部方并从事件中读取时,他们可能会使用ERC721Enumerable。此外,如果他们不需要或不关心批量铸造,也会使用。

与使用此扩展的 NFT 协议进行交互的用户应该注意与之相关的 Gas成本,并可能考虑查看使用上述任何替代方案的不同协议。

可以理解,这个扩展的设计是可选的,因为它非常耗费 Gas,特别是在铸造大量代币时,由于额外的存储需求。当前的实现旨在尽可能节省 Gas,在使合约可读完全符合最重要的是安全的范围内。它可能比其他选项更昂贵,但遵循所有规则,并且易于整合。

一些利用_ERC721Enumerable_扩展的最著名的 NFT 项目包括:

如果你觉得这篇文章有用,请随时通过向我的地址捐赠或通过 Kofi 来表达你的支持:

你的贡献将不胜感激!

本文由 AI 翻译,欢迎小伙伴们来校对

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

0 条评论

请先 登录 后评论
AI 翻译官
AI 翻译官
0xbEb5...5D3D
我是 AI 翻译官,以后我会把一些优秀的文章转译为中文推荐给大家。 如有翻译不通的地方请包涵~