可枚举的 ERC721(Enumerable ERC721)是增加了额外功能的 ERC721,它使得智能合约能够列出一个地址所拥有的所有 NFTs。本文将介绍 ERC721Enumerable 的工作原理,以及我们如何将其集成到现有的 ERC721 项目中。我们将使用 OpenZeppelin 流行的 ERC721Enumerable 实现来进行讲解。
前提条件
由于 ERC721Enumerable 是 ERC721 的扩展,本文假定读者已经阅读过我们的 ERC721 文章 或对 ERC721 标准有一定的了解。
交换并弹出 (Swap and Pop)
在 Solidity 中,从列表中移除一个元素通常的做法是:将最后一个元素复制到要移除的元素所在的位置,然后对数组执行 pop 操作(删除最后一个元素)。在 Gas 消耗方面,将所有元素向左移动的成本太高了。下面的动画展示了从列表中删除元素的操作,它移除了索引为 1 的元素(数字 5):
为什么需要 ERC721Enumerable?
为了理解为什么我们需要像 ERC721Enumerable 这样的扩展,让我们考虑一个示例场景。如果我们必须从一个特定的 ERC721 合约中找出某个钱包拥有的所有 NFTs,在仅使用 ERC721 现有功能的情况下我们该怎么做?
我们需要使用代币所有者的地址调用 balanceOf() 函数,这将告诉我们该地址拥有的 NFTs 数量。然后,我们需要遍历该 ERC721 合约中的所有 tokenIDs,并为每个 tokenIDs 调用 ownerOf() 函数。
假设 NFT 的总供应量为 1000,并且某个地址拥有两个 NFT:第一个和最后一个。也就是说,它拥有 tokenIDs #1 和 #1000。

为了找到该地址拥有的这 2 个 tokenIDs(代币 #1 和代币 #1000),我们将不得不遍历合约中的所有 NFTs 并查询该 ID 的 ownerOf()(从 1 到 1000),这在计算上是非常昂贵的。此外,我们并不总是知道合约中所有的 tokenIDs,所以我们可能根本无法做到这一点。
在接下来的章节中,我们将学习 ERC721Enumerable 是如何解决这个问题的。
追踪代币所有权的朴素解决方案
追踪一个地址所拥有的每个代币的朴素解决方案是:存储一个从地址到其所拥有 NFT 列表的 mapping。
mapping(address owner => uint256[] ownedIDs) public ownedTokens;
然而,由于以下原因,这种解决方案既低效又不完善:
-
如果用户拥有大量代币,智能合约在读取他们的数组时,可能会因为在内存中存储过长的数组而耗尽 Gas。
-
存在对 Gas 更高效的数据列表存储方式(后文讨论)。
-
如果我们想从用户的代币列表中移除某个特定的代币,我们需要扫描整个列表来寻找它。如果数组很长,我们可能会耗尽 Gas。
为了解决问题 1 和 2,ERC721 Enumerable 使用了数组而不是 mapping(见下一节);而为了解决第 3 个问题,需要一个额外的数据结构,将 tokenID 映射到它所在的索引位置。
将 mapping 用作数组
Mapping 的使用方式可以类似于数组,其中键(keys)充当 索引(index),而 值(values)则是数组中存储在该索引处的值。

如果我们将上面例子中的数组替换为 mapping,数组的 indexes 将成为键,而 tokenIDs 将成为值。
在 Solidity 中,mapping 比数组在 Gas 消耗上更高效。每当对数组进行索引时,都会隐式地检查数组的长度(即对于索引 i,它会检查 i < array.length 是否成立)。这种检查增加了使用数组的 Gas 成本。将 mapping 当作数组使用时,我们跳过了这种检查,从而节省了 Gas。
然而,与数组不同的是,mapping 没有内置的 length 属性,我们本可以使用该属性来追踪合约中 NFTs 的总数。因此,mapping 并不总是替代数组的好选择。
在下一节中,我们将逐一深入探讨 ERC721Enumerable 中的每一个数据结构。
ERC721Enumerable:数据结构
ERC721 Enumerable 追踪两件事:
- 所有现存的
tokenIDs。 - 某个地址拥有的所有
tokenIDs。
为了实现第 1 点,它使用了数据结构 _allTokens 和 _allTokensIndex。
为了实现第 2 点,它使用了数据结构 _ownedTokens 和 _ownedTokensIndex。

为了简单起见,我们将在每一个示例和解释中使用同一组 tokenID,即 2、5、9、7 和 1。
_allTokens 数组:

_allTokens 数组允许我们按顺序遍历合约中的所有 NFTs。_allTokens 私有数组保存了每一个存在的 tokenID(无论其所有权状态如何)。
最初,_allTokens 中 tokenIDs 的顺序取决于它们的铸造时间。在上面的图表中,tokenID #2 位于索引 #0 处,因为它是先于其他 tokenIDs 被铸造出来的。在 tokenIDs 被销毁时,这个顺序可能会发生变化。
_allTokensIndex mapping:
给定一个 tokenID,_allTokensIndex mapping 会返回该 tokenID 在 _allTokens 数组中的索引。
我们无需遍历 _allTokens 来寻找某个 tokenID 的索引,而是可以使用 _allTokensIndex mapping,通过 tokenID 本身来找到它在 _allTokens 中的索引。
能够快速找到 tokenID 使得 burn 函数可以高效地将其移除。

上图展示了 tokenIDs 到其对应索引值的映射。tokenID #2 映射到了第 0 个索引,因为它是合约中铸造的第一个代币。这个映射模式将对每一个被铸造的代币延续下去。
_ownedTokens mapping:
_ownedTokens mapping 用于追踪某个地址拥有的 tokenIDs。它包含一个嵌套的 mapping(即 owner -> index -> tokenID)。它将每一个 owner 地址映射到一个 index,该 index 位于该地址的代币余额范围内。每一个 index 又映射到该地址拥有的一个 tokenID。

在上图中,地址 ‘0xAli3c3’ 拥有 3 个 NFT,因此有 3 个 tokenIDs 的映射。另一个地址(0xb0b)拥有单个代币,因此只有一个 tokenID 的映射。在索引 #2 处,‘0xAli3c3’ 地址的嵌套映射指向了 tokenID #1。
_ownedTokensIndex mapping:
就像 _allTokensIndex 是 _allTokens 的镜像一样,_ownedTokenIndex 也是 _ownedTokens 的镜像。
_ownedTokensIndex 是一个从 tokenIDs 映射到该代币在对应用户的 _ownedTokens 中索引的 mapping。请参考下图:

如果我们将 tokenID 2 或 9 代入 _ownedTokensIndex,返回的结果都是 0,因为它们分别是 Alice 和 Bob 的“拥有的第一个代币”。
同样像 _allTokensIndex 一样,该数据结构的目的是在 _ownedTokens 中寻找特定的 tokenID,以便我们能够高效地移除它(例如当用户转移或销毁代币时)。
由于这些数据结构是私有的,因此无法直接与它们进行交互。在下一节中,我们将了解读取和操作这些数据结构的函数。
ERC721Enumerable:函数
根据 ERC721 文档,ERC721Enumerable 包含三个公共函数:
totalSupply()

此函数用于检索合约中现存的 NFT 总数。它返回 _allTokens 数组的长度。
tokenByIndex()

tokenByIndex 只是对 _allTokens 数组的简单封装,它接受一个索引作为输入,并返回存储在 _allTokens 数组中该索引处的 tokenID。
tokenOfOwnerByIndex()

此函数是对 _ownedTokens mapping 的封装,并附带了一些输入验证。

在上面的 _ownedTokens mapping 示例中,地址 ‘0xAli3c3’ 拥有 3 个 tokenIDs。如果调用此函数时传入该地址以及 index 参数为 2,则会返回 tokenID #1。
在枚举(Enumeration)中添加/移除 tokenIDs
除了这些函数之外,OpenZeppelin 的 ERC721Enumerable 实现还具有 4 个额外的私有函数,这些函数被 _update 函数调用,以确保 ERC721Enumerable 中的数据结构反映出当前的代币所有权。
我们不会深入讨论所有这些函数的细节,因为它们并不是 ERC721 规范的一部分。不过,让我们来看看其中一个:
removeTokenFromOwnerEnumeration()

当需要从某个地址的枚举数据结构中删除某个 tokenID 时,会用到此函数。如果所有者出售或销毁了他们的 NFT,该 NFT 的 tokenID 需要从所有者的地址解除关联,这就是 _removeTokenFromOwnerEnumeration 发挥作用的地方。
删除过程
在执行删除之前,该函数会使用 _ownedTokensIndex mapping 来检查 tokenId 是否位于该所有者拥有的 tokenIDs 的最后一个索引处。如果它不在最后一个索引处,它会与最后一个索引处的 tokenID 进行交换。
这一步是必要的,因为如果直接删除该 tokenID,就会在所有者的代币索引中留下一个空隙,这将导致当传入所有者地址调用 balanceOf() 函数时返回错误的结果。
在交换之后,该函数会从 _ownedTokensIndex 和 _ownedTokens 中删除该 tokenID(此时它已经是最后一个 tokenID 了),从而有效地将该代币从枚举中移除。
扩展中其余类似功能的函数如下:
_addTokenToOwnerEnumeration:每当有 tokenID 被铸造或转移到一个非零地址时,将一个 tokenID 添加到 _ownedTokens 和 _ownedTokensIndex 中。
它使用 balanceOf() 函数来确定可以分配给新铸造 tokenID 的 index。
对于拥有 3 个 tokenIDs 的地址,balanceOf() 将返回 3。这意味着索引 #3 可以分配给新铸造的 tokenID(因为索引是从 0 开始的)。

_addTokenToAllTokensEnumeration:每当有 tokenID 被铸造时,将其添加至追踪所有 NFTs 的数据结构中,例如 _allTokensIndex。

_removeTokenFromAllTokensEnumeration:在 tokenID 被销毁时使用,以保持数据结构的更新。
_removeTokenFromAllTokensEnumeration 遵循与 _removeTokenFromOwnerEnumeration 类似的删除过程。

将各部分组合起来:_update 函数
我们在上一节中简要了解的那 四个 私有函数,被 _update 函数用来进行 NFT 的铸造、销毁或转移。

每当 tokenID 的所有权发生改变时,它就会被调用。该函数中包含两对条件语句。让我们来看看它们在做些什么:
条件语句 #1:检查发送方地址
第一对条件检查 tokenID 是在被铸造还是被转移。它负责处理将 tokenID 从前一个所有者的数据结构中移除的操作。为 tokenID 分配所有权则由下一个条件语句处理。
情况 1:代币被铸造
如果它正在被铸造,将调用 _addTokenToAllTokensEnumeration,它会将 tokenID 添加到 _allTokens 和 _allTokensIndex 中。

情况 2:代币被转移
如果它正在被转移,将调用 _removeTokenFromOwnerEnumeration,这会从该函数传入的 previousOwner 地址的 _ownedTokens 和 _ownedTokensIndex 中移除该 tokenID。

条件语句 #2:检查接收方地址
第一个条件不关心 tokenID 正被转移到哪个地址。第二个条件语句才会检查 tokenID 是正在被销毁还是被转移到一个非零地址。
情况 1:代币被销毁
如果它正在被销毁,将调用 _removeTokenFromAllTokensEnumeration 函数,这会从 _allTokens 和 _allTokensIndex 中移除该 tokenID。

情况 2:代币被转移
如果它正被转移到一个非零地址,将调用 _addTokenToOwnerEnumeration,这会将该 tokenID 添加到目标(to)地址的 _ownedTokens 和 _ownedTokensIndex 中。

在你的项目中添加 ERC721Enumerable
在本节中,我们将学习如何通过 2 个步骤将 OpenZeppelin 的 ERC721Enumerable 扩展添加到我们的 ERC721 合约中。
1. 导入 ERC721Enumerable
在你的 ERC721 文件的顶部,将以下代码行与其余的导入一起添加进去:
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
之后,按照以下方式定义合约:
contract YourTokenName is ERC721, ERC721Enumerable{
}
2. 重写函数
引入 ERC721Enumerable 要求重写 ERC721 中的某些函数。这些函数是:
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);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721Enumerable)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
注意:实现了自定义 balanceOf() 函数的其他 ERC721 扩展(例如 ERC721Consecutive),由于会干扰其功能,因此无法与 ERC721Enumerable 扩展同时使用。
枚举的代价:ERC721Enumerable 扩展的注意事项
对于每一次转移,都必须更新 ERC721Enumerable 中的数据结构。这使得合约非常消耗 Gas,增加了一笔可观的 Gas 成本。不过,对于必须在链上列出 tokenIDs 的项目来说,这是一项必要的开支。
作者
本文由 RareSkills 的研究实习生 Poneta 撰写。
通过 RareSkills 了解更多
查看我们的 Solidity Bootcamp 以学习高级 Solidity 概念。
首次发布于 2024 年 3 月 27 日