概述
读者可前往我的博客获得更好的阅读体验。
本文主要介绍标准NFT实现的一个变体,即ERC721A
合约实现的相关细节。ERC721A
是由著名NFT系列Azuki提出,该系列NFT是著名的蓝筹NFT。本文主要聚焦于Azuki
提出的ERC721A
合约的代码细节分析。
与传统的ERC721
实现相比,ERC721A
在批量铸造(batch mint)方面具有显著的gas
优势,这得益于ERC721A
的惰性初始化方面的设计。关于ERC721A
与普通ERC721
实现的对比,我们将会在下文展开说明。
本文要求读者具有基础的solidity
知识,希望读者对标准ERC721
有所了解。
读者可在阅读本文前,酌情阅读以下参考材料:
本文基于目前的最新版本(4.2.3
)合约代码进行分析。
ERC721实现
由于下文涉及到ERC721A
与ERC721
的技术对比,考虑到部分读者可以对ERC721
合约实现并不清楚,本节简要的介绍ERC721
正常实现的铸造功能,本节主要基于solmate的实现版本。
solmate
实现都较为短小精悍且经过gas
优化,我个人较为推崇。solmate
的ERC721
实现仅有 231 行,读者可自行阅读。
在solmate
合约中,我们可以看到核心数据结构为:
mapping(uint256 => address) internal _ownerOf;
mapping(address => uint256) internal _balanceOf;
其中,各映射功能如下:
_ownerOf
记录 tokenId 与持有者的关系_balanceOf
记录持有人所持有的 NFT 数量
其铸造方法定义如下:
function _mint(address to, uint256 id) internal virtual {
require(to != address(0), "INVALID_RECIPIENT");
require(_ownerOf[id] == address(0), "ALREADY_MINTED");
// Counter overflow is incredibly unrealistic.
unchecked {
_balanceOf[to]++;
}
_ownerOf[id] = to;
emit Transfer(address(0), to, id);
}
通过此函数,我们更新了_ownerOf
和_balanceOf
实现用户铸造 NFT 的功能。我们可以发现用户每次铸造NFT都需要更新_ownerOf
和_balanceOf
映射。众所周知,在操作码gas
消耗中,更新存储需要消耗大量gas
。如果用户批量铸造,会在此过程中消耗大量gas
。
根据数据(PDF警告),在ETH价格为 1500 美元时,更新存储的价格为 7.5 美元,而写入存储的价格为 30 美元。这意味着仅在
mint
过程中,更新映射会浪费大量资产。
转账函数定义如下:
function transferFrom(
address from,
address to,
uint256 id
) public virtual {
require(from == _ownerOf[id], "WRONG_FROM");
require(to != address(0), "INVALID_RECIPIENT");
require(
msg.sender == from || isApprovedForAll[from][msg.sender] || msg.sender == getApproved[id],
"NOT_AUTHORIZED"
);
// Underflow of the sender's balance is impossible because we check for
// ownership above and the recipient's balance can't realistically overflow.
unchecked {
_balanceOf[from]--;
_balanceOf[to]++;
}
_ownerOf[id] = to;
delete getApproved[id];
emit Transfer(from, to, id);
}
由于对于每个tokenId
都维护有一个mapping
映射,所以转账逻辑实现也较为简单。
总体来看,对于每一个NFT,在solmate
实现的智能合约中,都维持有以下两个映射:
mapping(uint256 => address) internal _ownerOf;
标识NFT的拥有者mapping(uint256 => address) public getApproved;
记录NFT的授权情况
优势
在上一节中,我们介绍了常规NFT实现的基本情况,正如上文所述,常规实现在批量mint
铸造阶段会消耗大量gas
。为了解决这一问题,ERC721A
引入惰性初始化机制。简单来说,在批量铸造时,不再记录tokenId
与用户地址的映射关系,而是记录起始tokenId
和数量与用户的映射关系。在本节中,我们不对此实现的技术细节进行分析,我们会在本文稍后部分对此进行讨论。
在批量铸造阶段,ERC721A
与OpenZeppelin
实现的对比如下:
ERC721 | ERC721A | |
---|---|---|
批量铸造 5 个 NFT | 155949 gas | 63748 gas |
转移 5 个 NFT | 226655 gas | 334450 gas |
铸造的 Base Fee | 200 gwei | 200 gwei |
转移的 Base Fee | 40 gwei | 40 gwei |
总花费 | 0.0403 ether | 0.0261 ether |
如果读者对于此处的
gas
计算的细节感兴趣,可以阅读以太坊机制详解:Gas Price计算。我们在此处不详细讨论计算方式。我们可以注意到铸造阶段的Base fee
较高,这考虑到了NFT铸造导致的网络拥堵情况。
显然,惰性初始化机制对于批量铸造阶段的gas
节省是具有明显优势的,但惰性加载将初始化的成本转移到了转账部分,我们可以看到在转移NFT时的成本有所上升。但需要注意,第一次转账后由于彻底完成了初始化,所有后续转账的成本会降低,如下:
ERC721 | ERC721A | |
---|---|---|
First transfer | 45331 gas | 92822 gas |
Subsequent transfers | 45331 gas | 44499 gas |
通过表格可以看出,除第一次转账消耗的gas
明显增多,但随后转账的价格与常规的NFT转账并无区别。
总结来说,ERC721A
实现了低成本的批量铸造,但将部分成本转移到了第一次转账中。这种设计充分考虑到了铸造阶段可能出现的以太坊网络拥堵而造成gas
价格飙升的情况,而用户后期转账是偶发的且不会导致网络拥堵的。通过这种特殊的成本转嫁机制,ERC721A
降低用户的总成本。
换言之,如果您认为您的NFT项目不存在批量铸造的情况或不会导致以太坊网络拥堵,可以选择常规NFT实现。
具体实现
在讨论了ERC721A
的基本内容后,为进一步增加我们对ERC721A
的理解,我们将对其合约进行阅读分析。ERC721A
的开源仓库位于github。此处,我们仅讨论ERC721A
的主合约,而暂不讨论extensions
部分。
对于NFT合约的分析,存储数据结构和_mint
函数是一个很好的入手点。我们首先关注存储数据结构。
在NFT数据存储中,我们可以看到solmate
等常规实现都使用了mapping(uint256 => address) internal _ownerOf
将单个tokenId
与持有者对应。但ERC721A
是对批量铸造进行特殊优化的,开发者认为在批量铸造过程中,用户持有的NFT的tokenId
往往是连续的,如下图:
基本数据结构
在批量铸造过程中,用户铸造连续的NFT是极其常见的。为了实现连续分配tokenID
以降低gas
消耗的目的,我们需要一些更加复杂的数据结构设计,具体代码设计如下:
// The next token ID to be minted.
uint256 private _currentIndex;
// The number of tokens burned.
uint256 private _burnCounter;
// Token name
string private _name;
// Token symbol
string private _symbol;
// Mapping from token ID to ownership details
// An empty struct value does not necessarily mean the token is unowned.
// See {_packedOwnershipOf} implementation for details.
//
// Bits Layout:
// - [0..159] `addr`
// - [160..223] `startTimestamp`
// - [224] `burned`
// - [225] `nextInitialized`
// - [232..255] `extraData`
mapping(uint256 => uint256) private _packedOwnerships;
// Mapping owner address to address data.
//
// Bits Layout:
// - [0..63] `balance`
// - [64..127] `numberMinted`
// - [128..191] `numberBurned`
// - [192..255] `aux`
mapping(address => uint256) private _packedAddressData;
// Mapping from token ID to approved address.
mapping(uint256 => TokenApprovalRef) private _tokenApprovals;
// Mapping from owner to operator approvals
mapping(address => mapping(address => bool)) private _operatorApprovals;
与其他简单参数相比,我们主要关注复杂的参数:
_packedOwnerships
类似常规NFT实现中的_ownerOf
,我们通过此映射查询某tokenID
的拥有者,但此结构是打包方式的,即我们并不