如果你已经熟悉 EIP-721 标准 ,你可能已经意识到,有趣的是,其设计的一个关键限制是它没有提供一个内置方法来列出特定地址拥有的所有代币,甚至没有列出所有流通中的代币。 这种限制对于需要这些功能的应用程序可能会有问题。例如,一个市场可能需要显示特定艺术家创建的所有 NFT,或者一个游戏可能需要展示玩家拥有的所有独特游戏物品。这就是ERC721Enumerable扩展发挥作用的地方。以下是从 EIP-721 规范中提取的官方描述:
ERC721Enumerable扩展通过引入额外的数据结构和函数来解决上述限制,从而使一些应用程序能够列出可能有用的代币。没有这个扩展,开发人员将不得不实现自己的机制来跟踪和枚举代币,这将容易出现混乱、错误和安全风险。
基本用法
要直接使用ERC721Enumerable扩展非常简单,只需导入它并继承到你的ERC721合约中,并覆盖一些函数以能够成功编译合约。
如果我们想尽快使用它,我们可以直接转到 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)
以获取特定索引处的 tokenIDtokenOfOwnerByIndex(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
数组中的项目,这在创建和转移代币时不会发生。
当开发人员需要一种方式来跟踪合约内所有代币或特定帐户拥有的所有代币,而不希望依赖外部方并从事件中读取时,他们可能会使用_ERC721Enumerable_。此外,如果他们不需要或不关心批量铸造,也会使用。
与使用此扩展的 NFT 协议进行交互的用户应该注意与之相关的 Gas成本,并可能考虑查看使用上述任何替代方案的不同协议。
可以理解,这个扩展的设计是可选的,因为它非常耗费 Gas,特别是在铸造大量代币时,由于额外的存储需求。当前的实现旨在尽可能节省 Gas,在使合约可读、完全符合和最重要的是安全的范围内。它可能比其他选项更昂贵,但遵循所有规则,并且易于整合。
一些利用_ERC721Enumerable_扩展的最著名的 NFT 项目包括: