本文详细介绍了ERC721Enumerable扩展的功能及其在现有ERC721项目中的集成方法,包括其数据结构、函数实现以及如何通过OpenZeppelin的ERC721Enumerable扩展代码将其添加到项目中。
一个 Enumerable ERC721 是一个带有额外功能的 ERC721,使智能合约能够列出某个地址拥有的所有 NFT。本文描述了 ERC721Enumerable 如何运作以及我们如何将其集成到现有的 ERC721 项目中。我们将使用 Open Zeppelin 流行的 ERC721Enumerable 实现作为我们的说明。
由于 ERC721Enumerable 是 ERC721 的扩展,本文假设读者已经阅读过我们的 ERC721 文章 或对 ERC721 标准有一定了解。
在 Solidity 中,从列表中移除一个项目通常是通过将最后一个元素复制到要移除的项目的目标,然后弹出数组(删除最后一个元素)来完成的。将所有元素向左移动在 gas 成本上是非常昂贵的。删除列表中的操作在下面的动画中展示,该动画移除了索引为 1(数字 5)的项目:
https://img.learnblockchain.cn/2025/02/26/file.mp4
为了理解我们为什么需要像 ERC721Enumerable 这样的扩展,让我们考虑一个示例场景。如果我们需要找到某个钱包在特定 ERC721 合约中拥有的所有 NFTs,我们该如何利用 ERC721 中现有的功能来实现?
我们需要用 token 拥有者的地址调用 balanceOf() 函数,这将返回该地址拥有的 NFTs 数量。接下来,我们将循环遍历 ERC721 合约中的所有 tokenIDs,并对每个 tokenID 调用 ownerOf() 函数。
假设总的 NFT 供应量为 1000,而某个地址拥有两枚 NFT,分别是第一枚和最后一枚。也就是说,它拥有 tokenIDs #1 和 #1000。

为了找到该地址拥有的两枚 tokenIDs(token #1 和 token #1000),我们必须遍历合约中所有的 NFTs 并对这些 ID(从 1 到 1000)查询 ownerOf(),这在计算上是非常昂贵的。此外,我们并不总是知道合约中所有的 tokenIDs,因此可能无法做到这一点。
在接下来的部分中,我们将了解 ERC721Enumerable 如何解决这个问题。
跟踪每个地址拥有的 token 的简单解决方案是存储从地址到其拥有的 NFT 列表的映射。
mapping(address owner => uint256[] ownedIDs) public ownedTokens;
然而,这个解决方案效率低下并且不完整,原因如下:
如果用户拥有大量的 token,智能合约读取其数组可能会耗尽 gas,将非常长的数组存储在内存中。
存储数据列表有更具 gas 效率的方法(稍后讨论)。
如果我们想从用户的 token 列表中移除某个特定的 token,我们需要扫描整个列表来找到它。如果数组非常长,我们可能会耗尽 gas。
为了解决 1 和 2,ERC721 Enumerable 使用数组而不是映射(见下一个部分),为了解决第 3 个问题,需要一个额外的数据结构,将 tokenID 映射到其在数组中的索引。
Mappings 可以以类似数组的方式使用,其中键是 index,值是存储在该索引内的值。

如果我们在上述示例中用 mapping 替换数组,数组的 indexes 作为键,tokenIDs 作为值。
在 Solidity 中,mappings 的 gas 效率比数组高。数组的长度在每次索引数组时隐式检查(即,在索引 i 时,会检查 i < array.length)。这个检查增加了使用数组的 gas 成本。使用 mapping 作为数组,我们可以跳过这个检查,因此节省 gas。
但是,与数组不同,mappings 没有内置的长度属性,我们无法使用该属性来跟踪合约中 NFTs 的总数。因此,mappings 不总是合适的数组替代品。
在下一部分中,我们将逐个深入研究 ERC721Enumerable 中的每个数据结构。
ERC721 Enumerable 跟踪两件事情:
tokenIDs。tokenIDs。为实现 1,它使用了数据结构 _allTokens 和 _allTokensIndex。
为实现 2,它使用了数据结构 _ownedTokens 和 _ownedTokensIndex。

为了简单起见,我们将在每个示例和说明中使用相同的一组 tokenIDs,即 2、5、9、7 和 1。

_allTokens 数组使我们能够按顺序遍历合约中的所有 NFTs。_allTokens 私有数组持有所有现有的 tokenID(无论其拥有状态如何)。
最初,_allTokens 中 tokenIDs 的顺序取决于它们被铸造的时间。在上面的图中,tokenID #2 在索引 #0 处,因为它在其他 tokenIDs 之前被铸造。这个顺序在 tokenIDs 被销毁时可能会改变。
_allTokensIndex 映射,给定一个 tokenID,返回该 tokenID 在 _allTokens 数组中的索引。
我们可以使用 _allTokensIndex 映射,而不是遍历 _allTokens 来找到 tokenID 的索引。
能够快速找到 tokenID 使得销毁功能能够高效地移除 tokenID。

上面的图说明了 tokenIDs 及其对应索引值的映射。tokenID #2 映射到 0th 索引,因为它是合约中铸造的第一个 token。这个映射模式会持续到每个被铸造的 token。
_ownedTokens 映射用于跟踪一个地址拥有的 tokenIDs。它有一个嵌套映射(即,owner -> index -> tokenID)。它将每个 owner 地址映射到一个 index,该 index 在地址的 token 余额范围内。每个索引映射到该地址拥有的一个 tokenID。

在上面的图中,地址 ‘0xAli3c3’ 拥有 3 个 NFT,因此为 3 个 tokenIDs 创建了映射。另一个地址 (0xb0b) 拥有一个 token,因此为一个 tokenID 创建了映射。在索引为 #2 的位置,嵌套映射 ‘0xAli3c3’ 地址映射到 tokenID #1。
就像 _allTokensIndex 是 _allTokens 的镜像映射一样,_ownedTokensIndex 是 _ownedTokens 的镜像映射。
_ownedTokensIndex 是一个从 tokenIDs 到该用户在 _ownedTokens 的索引的映射。考虑以下图示:

如果我们将 tokenID 2 或 9 插入到 _ownedTokensIndex 中,我们得到的都是 0,因为这是 Alice 和 Bob 的“第一个拥有的 token”。
同样,和 _allTokensIndex 一样,这个数据结构的目的就是在 _ownedTokens 中寻找特定的 tokenID,以便高效地将其移除(例如,当用户转移或销毁 token 时)。
由于这些数据结构是私有的,因此无法直接与之交互。在下一部分中,我们将了解读取和操作这些数据结构的函数。
根据 ERC721 文档,ERC721Enumerable 有三个公共函数:
totalSupply()
此函数用于检索合约中存在的 NFT 总数。它返回 _allTokens 数组的长度。
tokenByIndex()
tokenByIndex 是 _allTokens 数组的简单封装,接受一个索引作为输入,并返回存储在 _allTokens 数组中的该索引处的 tokenID。
tokenOfOwnerByIndex()
此函数是 _ownedTokens 映射的封装,并带有一些输入验证。

在上述 _ownedTokens 映射示例中,地址 ‘0xAli3c3‘ 拥有 3 个 tokenIDs。如果使用此地址和索引 2 调用函数,则返回的 tokenID 为 #1。
除了这些函数之外,OpenZeppelin 的 ERC721Enumerable 实现还有 4 个附加私有函数,这些函数通过 _update 函数确保 ERC721Enumerable 中的数据结构反映当前的 token 拥有权。
我们将不会详细讲解所有这些函数,因为它们并不是 ERC721 规范的一部分。然而,我们来看一下其中的一个:
removeTokenFromOwnerEnumeration()
当需要从地址的枚举数据结构中删除一个 tokenID 时使用该函数。如果所有者出售或销毁其 NFT,需要将该 NFT 的 tokenID 与所有者的地址解绑,这就是 _removeTokenFromOwnerEnumeration 发挥作用的地方。
在删除发生之前,该函数会使用 _ownedTokensIndex 映射来检查 tokenId 是否在该所有者的拥有 tokenIDs 的最后索引处。如果它不在最后索引,则将其与最后索引处的 tokenID 进行交换。
这是必要的,因为如果直接删除 tokenID,所有者的 token 索引中将留下一个空隙,这将导致在使用所有者的地址调用 balanceOf() 函数时返回不正确的结果。
交换后,该函数从 _ownedTokensIndex 和 _ownedTokens 中删除 tokenID(现在是最后的 tokenID),有效地将该 token 从枚举中移除。
扩展中其余这样的函数包括:
_addTokenToOwnerEnumeration : 每当 mint 或转移给非零地址时,将 tokenID 添加到 _ownedTokens 和 _ownedTokensIndex。
它使用 balanceOf() 函数来确定可以分配给新铸造的 tokenID 的 index。
balanceOf() 将返回 3,表示某个地址拥有 3 个 tokenIDs。这意味着索引 #3 可以分配给新铸造的 tokenID(因为索引从 0 开始)。

_addTokenToAllTokensEnumeration : 每当一个 tokenID 被铸造时,将该 tokenID 添加到跟踪所有 NFTs 的数据结构中,比如 _allTokensIndex。

_removeTokenFromAllTokensEnumeration : 当一个 tokenID 被销毁时用来保持数据结构更新。
__removeTokenFromAllTokensEnumeration_ 遵循与 __removeTokenFromOwnerEnumeration_ _类似的删除过程。

我们在前一节中简单学习的 四 个私有函数由 _update 函数使用,用于 mint、burn 或转移 NFTs。

每当 tokenID 的所有权发生变化时,它就会被调用。函数中包含两对条件语句。让我们理解它们正在做什么:
第一对语句检查 tokenID 是否正在被铸造或转移。它处理从之前所有者的数据结构中移除 tokenID。将所有者分配给 tokenID 的操作在下一个条件语句中处理。
案例 1:Token 被铸造
如果正在铸造,调用 _addTokenToAllTokensEnumeration,这将把 tokenID 添加到 _allTokens 和 _allTokensIndex 中。

案例 2:Token 被转移
如果正在被转移,调用 _removeTokenFromOwnerEnumeration,这将从之前所有者的 previousOwner 地址的 _ownedTokens 和 _ownedTokensIndex 中移除 tokenID,该地址作为函数的输入。

第一个条件与 tokenID 被转移到的地址无关。第二个条件语句检查 tokenID 是否被销毁或转移到非零地址。
案例 1:Token 被销毁
如果正在销毁,调用 _removeTokenFromAllTokensEnumeration 函数,它将从 _allTokens 和 _allTokensIndex 中移除 tokenID。

案例 2:Token 被转移
如果被转移到非零地址,调用 _addTokenToOwnerEnumeration,它将把 tokenID 添加到 to 地址的 _ownedTokens 和 _ownedTokensIndex 中。

在本节中,我们将学习如何在 2 个步骤中将 OpenZeppelin 的 ERC721Enumerable 扩展添加到我们的 ERC721 合约中。
在你的 ERC721 文件的顶部,在其余导入中添加以下代码行:
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
随后,在以下方式中定义合约:
contract YourTokenName is ERC721, ERC721Enumerable{
}
包含 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 中的数据结构都必须更新。这使合同变得耗费 gas,增加了相当大的 gas 成本。然而,对于必须在链上列出 tokenIDs 的项目而言,这是一项必要的开支。
本文由 RareSkills 的研究实习生 Poneta 撰写。
查看我们的 Solidity Bootcamp 学习高级 Solidity 概念。
首次发布于 2024 年 3 月 27 日
- 原文链接: rareskills.io/post/erc-7...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!