ERC721可枚举如何工作

本文详细介绍了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?

为了理解我们为什么需要像 ERC721Enumerable 这样的扩展,让我们考虑一个示例场景。如果我们需要找到某个钱包在特定 ERC721 合约中拥有的所有 NFTs,我们该如何利用 ERC721 中现有的功能来实现?

我们需要用 token 拥有者的地址调用 balanceOf() 函数,这将返回该地址拥有的 NFTs 数量。接下来,我们将循环遍历 ERC721 合约中的所有 tokenIDs,并对每个 tokenID 调用 ownerOf() 函数。

假设总的 NFT 供应量为 1000,而某个地址拥有两枚 NFT,分别是第一枚和最后一枚。也就是说,它拥有 tokenIDs #1 和 #1000。

一组 token id

为了找到该地址拥有的两枚 tokenIDs(token #1 和 token #1000),我们必须遍历合约中所有的 NFTs 并对这些 ID(从 1 到 1000)查询 ownerOf(),这在计算上是非常昂贵的。此外,我们并不总是知道合约中所有的 tokenIDs,因此可能无法做到这一点。

在接下来的部分中,我们将了解 ERC721Enumerable 如何解决这个问题。

跟踪 Token 拥有权的简单解决方案

跟踪每个地址拥有的 token 的简单解决方案是存储从地址到其拥有的 NFT 列表的映射。

mapping(address owner => uint256[] ownedIDs) public ownedTokens;

然而,这个解决方案效率低下并且不完整,原因如下:

  1. 如果用户拥有大量的 token,智能合约读取其数组可能会耗尽 gas,将非常长的数组存储在内存中。

  2. 存储数据列表有更具 gas 效率的方法(稍后讨论)。

  3. 如果我们想从用户的 token 列表中移除某个特定的 token,我们需要扫描整个列表来找到它。如果数组非常长,我们可能会耗尽 gas。

为了解决 12,ERC721 Enumerable 使用数组而不是映射(见下一个部分),为了解决第 3 个问题,需要一个额外的数据结构,将 tokenID 映射到其在数组中的索引。

将 Mapping 用作数组

Mappings 可以以类似数组的方式使用,其中键是 index,值是存储在该索引内的值。

一个展示如何将 mapping 用作数组的示例图

如果我们在上述示例中用 mapping 替换数组,数组的 indexes 作为键,tokenIDs 作为值。

在 Solidity 中,mappings 的 gas 效率比数组高。数组的长度在每次索引数组时隐式检查(即,在索引 i 时,会检查 i < array.length)。这个检查增加了使用数组的 gas 成本。使用 mapping 作为数组,我们可以跳过这个检查,因此节省 gas。

但是,与数组不同,mappings 没有内置的长度属性,我们无法使用该属性来跟踪合约中 NFTs 的总数。因此,mappings 不总是合适的数组替代品。

在下一部分中,我们将逐个深入研究 ERC721Enumerable 中的每个数据结构。

ERC721Enumerable:数据结构

ERC721 Enumerable 跟踪两件事情:

  1. 所有存在的 tokenIDs
  2. 一个地址拥有的所有 tokenIDs

为实现 1,它使用了数据结构 _allTokens_allTokensIndex

为实现 2,它使用了数据结构 _ownedTokens_ownedTokensIndex

ERC-721 Enumerable 的状态变量高亮显示

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

_allTokens 数组:

_allTokens 数组

_allTokens 数组使我们能够按顺序遍历合约中的所有 NFTs_allTokens 私有数组持有所有现有的 tokenID(无论其拥有状态如何)。

最初,_allTokenstokenIDs 的顺序取决于它们被铸造的时间。在上面的图中,tokenID #2 在索引 #0 处,因为它在其他 tokenIDs 之前被铸造。这个顺序在 tokenIDs 被销毁时可能会改变。

_allTokensIndex 映射:

_allTokensIndex 映射,给定一个 tokenID,返回该 tokenID 在 _allTokens 数组中的索引。

我们可以使用 _allTokensIndex 映射,而不是遍历 _allTokens 来找到 tokenID 的索引。

能够快速找到 tokenID 使得销毁功能能够高效地移除 tokenID

一个展示如何 _allTokensIndex 持有 _allTokens 数组中 tokenIDs 索引的示例图

上面的图说明了 tokenIDs 及其对应索引值的映射。tokenID #2 映射到 0th 索引,因为它是合约中铸造的第一个 token。这个映射模式会持续到每个被铸造的 token。

_ownedTokens 映射:

_ownedTokens 映射用于跟踪一个地址拥有的 tokenIDs。它有一个嵌套映射(即,owner -> index -> tokenID)。它将每个 owner 地址映射到一个 index,该 index 在地址的 token 余额范围内。每个索引映射到该地址拥有的一个 tokenID

一个展示 _ownedTokens 映射如何将地址映射到索引到 tokenID 的示例图

在上面的图中,地址 ‘0xAli3c3’ 拥有 3 个 NFT,因此为 3 个 tokenIDs 创建了映射。另一个地址 (0xb0b) 拥有一个 token,因此为一个 tokenID 创建了映射。在索引为 #2 的位置,嵌套映射 ‘0xAli3c3’ 地址映射到 tokenID #1。

_ownedTokensIndex 映射:

就像 _allTokensIndex_allTokens 的镜像映射一样,_ownedTokensIndex_ownedTokens 的镜像映射。

_ownedTokensIndex 是一个从 tokenIDs 到该用户在 _ownedTokens 的索引的映射。考虑以下图示:

一个展示 _ownedTokensIndex 如何保存 _ownedTokens 中 token 在的位置的示例图

如果我们将 tokenID 29 插入到 _ownedTokensIndex 中,我们得到的都是 0,因为这是 Alice 和 Bob 的“第一个拥有的 token”。

同样,和 _allTokensIndex 一样,这个数据结构的目的就是在 _ownedTokens 中寻找特定的 tokenID,以便高效地将其移除(例如,当用户转移或销毁 token 时)。

由于这些数据结构是私有的,因此无法直接与之交互。在下一部分中,我们将了解读取和操作这些数据结构的函数。

ERC721Enumerable:函数

根据 ERC721 文档,ERC721Enumerable 有三个公共函数:

totalSupply()

totalSupply\(\) 函数

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

tokenByIndex()

tokenByIndex\(\) 函数

tokenByIndex_allTokens 数组的简单封装,接受一个索引作为输入,并返回存储在 _allTokens 数组中的该索引处的 tokenID

tokenOfOwnerByIndex()

tokenOfOwnerByIndex\(\) 函数

此函数是 _ownedTokens 映射的封装,并带有一些输入验证。

_ownedTokens 的视觉示例图

在上述 _ownedTokens 映射示例中,地址 ‘0xAli3c3‘ 拥有 3 个 tokenIDs。如果使用此地址和索引 2 调用函数,则返回的 tokenID 为 #1。

从枚举中添加/移除 tokenIDs

除了这些函数之外,OpenZeppelin 的 ERC721Enumerable 实现还有 4 个附加私有函数,这些函数通过 _update 函数确保 ERC721Enumerable 中的数据结构反映当前的 token 拥有权。

我们将不会详细讲解所有这些函数,因为它们并不是 ERC721 规范的一部分。然而,我们来看一下其中的一个:

removeTokenFromOwnerEnumeration()

_removeTokenFromOwnerEnumeration\(\) 函数

当需要从地址的枚举数据结构中删除一个 tokenID 时使用该函数。如果所有者出售或销毁其 NFT,需要将该 NFT 的 tokenID 与所有者的地址解绑,这就是 _removeTokenFromOwnerEnumeration 发挥作用的地方。

删除过程

在删除发生之前,该函数会使用 _ownedTokensIndex 映射来检查 tokenId 是否在该所有者的拥有 tokenIDs 的最后索引处。如果它不在最后索引,则将其与最后索引处的 tokenID 进行交换。

这是必要的,因为如果直接删除 tokenID,所有者的 token 索引中将留下一个空隙,这将导致在使用所有者的地址调用 balanceOf() 函数时返回不正确的结果。

交换后,该函数从 _ownedTokensIndex_ownedTokens 中删除 tokenID(现在是最后的 tokenID),有效地将该 token 从枚举中移除。

扩展中其余这样的函数包括:

_addTokenToOwnerEnumeration : 每当 mint 或转移给非零地址时,将 tokenID 添加到 _ownedTokens_ownedTokensIndex

它使用 balanceOf() 函数来确定可以分配给新铸造的 tokenIDindex

balanceOf() 将返回 3,表示某个地址拥有 3 个 tokenIDs。这意味着索引 #3 可以分配给新铸造的 tokenID(因为索引从 0 开始)。

_addTokenToOwnerEnumeration\(\) 函数

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

_addTokenToAllTokensEnumeration\(\) 函数

_removeTokenFromAllTokensEnumeration : 当一个 tokenID 被销毁时用来保持数据结构更新。

__removeTokenFromAllTokensEnumeration_ 遵循与 __removeTokenFromOwnerEnumeration_ _类似的删除过程。

_removeTokenFromAllTokensEnumeration\(\) 函数

将所有部分组合在一起:_updateFunction

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

ERC721 Enumerable _update 函数

每当 tokenID 的所有权发生变化时,它就会被调用。函数中包含两对条件语句。让我们理解它们正在做什么:

条件语句 #1:检查发送者地址

第一对语句检查 tokenID 是否正在被铸造或转移。它处理从之前所有者的数据结构中移除 tokenID。将所有者分配给 tokenID 的操作在下一个条件语句中处理。

案例 1:Token 被铸造

如果正在铸造,调用 _addTokenToAllTokensEnumeration,这将把 tokenID 添加到 _allTokens_allTokensIndex 中。

一幅展示在 _addTokensToAllTokenEnumeration 中状态变化代码的图

案例 2:Token 被转移

如果正在被转移,调用 _removeTokenFromOwnerEnumeration,这将从之前所有者的 previousOwner 地址的 _ownedTokens_ownedTokensIndex 中移除 tokenID,该地址作为函数的输入。

一幅展示在 _removeTokenFromOwnerEnumeration\(\) 函数中从 _ownedTokens 和 _ownedTokensIndex 删除 tokenID 的状态变化代码的图

条件语句 #2:检查接收者地址

第一个条件与 tokenID 被转移到的地址无关。第二个条件语句检查 tokenID 是否被销毁或转移到非零地址。

案例 1:Token 被销毁

如果正在销毁,调用 _removeTokenFromAllTokensEnumeration 函数,它将从 _allTokens_allTokensIndex 中移除 tokenID

该图展示了删除 _allTokensIndex 中token的状态变化代码

案例 2:Token 被转移

如果被转移到非零地址,调用 _addTokenToOwnerEnumeration,它将把 tokenID 添加到 to 地址的 _ownedTokens_ownedTokensIndex 中。

该图展示 ‌_addTokenToOwnerEnumeration\(\) 函数中添加 token 到 _ownedTokens 的状态变化代码

将 ERC721Enumerable 添加到你的项目

在本节中,我们将学习如何在 2 个步骤中将 OpenZeppelin 的 ERC721Enumerable 扩展添加到我们的 ERC721 合约中。

1. 导入 ERC721Enumerable

在你的 ERC721 文件的顶部,在其余导入中添加以下代码行:

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

随后,在以下方式中定义合约:

contract YourTokenName is ERC721, ERC721Enumerable{

}

2. 重写函数

包含 ERC721Enumerable 需要重写 ERC721 中的一些函数。这些函数是:

  1. _update
function _update(
    address to,
    uint256 tokenId,
    address auth
) internal override(ERC721, ERC721Enumerable) returns (address) {
    return super._update(to, tokenId, auth);
}
  1. _increaseBalance
function _increaseBalance(address account, uint128 value)
    internal
    override(ERC721, ERC721Enumerable)
{
    super._increaseBalance(account, value);
}
  1. supportsInterface
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 日

  • 原文链接: rareskills.io/post/erc-7...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

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