Azuki 项目方推出了一个新的 ERC721 标准,名叫 ERC721A,主要是对一次 mint 多个 NFT 的时候做了 Gas 优化。这篇文章就来看看 ERC721A 到底有什么神奇之处。
我们先用图示来说明 ERC721A 和标准的 ERC721 有什么区别。
标准的 ERC721,数据结构中对于每一个 tokenID,都记录了其 owner 相关信息,这样做的好处是逻辑清晰。但同时也带来一个问题就是数据冗余,一般批量 mint 的时候,连续几个 tokenID 的 owner 都是同一个地址,那必然在每一次设置的时候会消耗 Gas,那么有没有办法对这里进行优化呢。我们来看看 ERC721A 的做法:
在 ERC721A 的实现中,如果某一段连续的 tokenID 都被同一个用户拥有,那么只在第一个位置上记录相关信息,这样就会省去在接下来的位置上设置信息而导致的 Gas 消耗。
那么如果我们想要查询某个 ID 的 owner 是谁应该怎么操作呢。
如上图所示,如果我们想要查找 ID 为 N 的 NFT,直接查询到当前 owner 是 Alice。如果我们要查询 N + 2 的 NFT,此时相应的数据为空,那么就向前查找,直到找到第一个非空的数据位,就是对应的 owner 信息。
我们再来考虑一个场景,下图中,Alice 拥有从 N 到 N + 4 这些 ID
如果 Alice 想把第 N + 2 个 NFT 送给 Cindy,那么结构就会变成
但是这样就会带来一个问题,当我们想要查询 N + 3 的 owner 时,结果为空。我们按照前面的做法,向前查找第一个不为空的数据时,找到了 N + 2 的 Cindy,也就是说 N + 3 的 owner 现在变成了 Cindy!(N + 4 也是)
那么该如何解决这个问题呢,其实很简单,当发生转账行为时,将当前 ID 的后一个 ID 相关信息设置为转出人的信息。也就是说,再次显式设置一次 owner 信息:
将 N + 3 的 owner 显式地设置成 Alice,这样在查找 N + 3 和 N + 4 的时候,owner 信息依然是正确的。
上述就是 ERC721A 的主要特性,主要是去除一些冗余数据,这样可以在批量 mint 的时候节省 Gas。
我们来看看代码。(注,ERC721A 的代码库一直在更新,因此可能与当前代码有所出入)
新定义了一些数据结构:
// 所有权信息
struct TokenOwnership {
// owner地址
address addr;
// 记录的是该NFT所有权变更的时间
uint64 startTimestamp;
// 是否已经销毁
bool burned;
}
// 用户地址资产信息
struct AddressData {
// 该地址有多少个NFT
uint64 balance;
// 该地址已经mint了多少个NFT
uint64 numberMinted;
// 该地址已经销毁了多少个NFT
uint64 numberBurned;
// 存储一些额外信息(用作缺省位置)
uint64 aux;
}
// tokenId -> 所有权信息
mapping(uint256 => TokenOwnership) internal _ownerships;
// 用户地址 -> 地址相关资产信息
mapping(address => AddressData) private _addressData;
我们来看看查找 owner 的代码:
function _ownershipOf(uint256 tokenId) internal view returns (TokenOwnership memory) {
uint256 curr = tokenId;
unchecked {
// 查询ID要在有效范围内
if (_startTokenId() <= curr && curr < _currentIndex) {
TokenOwnership memory ownership = _ownerships[curr];
// 要求ID未被burn
if (!ownership.burned) {
// 如果查询到当前ID有对应的信息,直接返回相应结果
if (ownership.addr != address(0)) {
return ownership;
}
// 否则,如果当前位置对应的信息为空,向前查找第一个不为
// 空的位置
while (true) {
curr--;
ownership = _ownerships[curr];
if (ownership.addr != address(0)) {
return ownership;
}
}
}
}
}
revert OwnerQueryForNonexistentToken();
}
mint 的代码:
function _mint(
address to,
uint256 quantity,
bytes memory _data,
bool safe
) internal {
// 校验信息
uint256 startTokenId = _currentIndex;
if (to == address(0)) revert MintToZeroAddress();
if (quantity == 0) revert MintZeroQuantity();
_beforeTokenTransfers(address(0), to, startTokenId, quantity);
unchecked {
// 更新mint用户的信息
_addressData[to].balance += uint64(quantity);
_addressData[to].numberMinted += uint64(quantity);
// 更新ID对应的地址信息
_ownerships[startTokenId].addr = to;
_ownerships[startTokenId].startTimestamp = uint64(block.timestamp);
uint256 updatedIndex = startTokenId;
uint256 end = updatedIndex + quantity;
// 可以看到循环中并没有设置信息
if (safe && to.isContract()) {
// 如果需要safe mint并且to是合约
do {
emit Transfer(address(0), to, updatedIndex);
if (!_checkContractOnERC721Received(address(0), to, updatedIndex++, _data)) {
revert TransferToNonERC721ReceiverImplementer();
}
} while (updatedIndex != end);
// Reentrancy protection
if (_currentIndex != startTokenId) revert();
} else {
// 如果不需要safe mint或者to不是合约
do {
emit Transfer(address(0), to, updatedIndex++);
} while (updatedIndex != end);
}
// 在最后更新Index信息
_currentIndex = updatedIndex;
}
_afterTokenTransfers(address(0), to, startTokenId, quantity);
}
转账的代码:
function _transfer(
address from,
address to,
uint256 tokenId
) private {
TokenOwnership memory prevOwnership = _ownershipOf(tokenId);
if (prevOwnership.addr != from) revert TransferFromIncorrectOwner();
bool isApprovedOrOwner = (_msgSender() == from ||
isApprovedForAll(from, _msgSender()) ||
getApproved(tokenId) == _msgSender());
if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved();
if (to == address(0)) revert TransferToZeroAddress();
_beforeTokenTransfers(from, to, tokenId, 1);
// 清除授权信息
_approve(address(0), tokenId, from);
unchecked {
// 更新from与to的资产信息
_addressData[from].balance -= 1;
_addressData[to].balance += 1;
TokenOwnership storage currSlot = _ownerships[tokenId];
currSlot.addr = to;
currSlot.startTimestamp = uint64(block.timestamp);
// 若下一个位置信息为空,则显式地将其设置为from地址
uint256 nextTokenId = tokenId + 1;
TokenOwnership storage nextSlot = _ownerships[nextTokenId];
if (nextSlot.addr == address(0)) {
if (nextTokenId != _currentIndex) {
nextSlot.addr = from;
nextSlot.startTimestamp = prevOwnership.startTimestamp;
}
}
}
emit Transfer(from, to, tokenId);
_afterTokenTransfers(from, to, tokenId, 1);
}
burn 的代码:
_ownerships[tokenId].addr = to;
_ownerships[tokenId].startTimestamp = uint64(block.timestamp);
// 最初认为下面的代码没有必要
// If the ownership slot of tokenId+1 is not explicitly set, that means the burn initiator owns it.
// Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls.
uint256 nextTokenId = tokenId + 1;
TokenOwnership storage nextSlot = _ownerships[nextTokenId];
if (nextSlot.addr == address(0)) {
if (nextTokenId != _currentIndex) {
nextSlot.addr = from;
nextSlot.startTimestamp = prevOwnership.startTimestamp;
}
}
burn 的代码转账代码比较相似,这里我节选出这一块的原因是,我最开始看到这里时,认为这段代码的没有必要的,因为注释说到这一块的目的是为了保证 ownerOf(tokenId+1) 的正确性。但是这块去掉也不影响,ownerOf(tokenId+1) 仍然是正确的。后来我在官方的 GitHub 上和开发人员讨论了一下,这里确实是需要的。因为在 burn 的时候首先将当前的 ID 的 startTimestamp 设置成了当前的时间,如果去掉了这一段,如果在查询后面 ID 的时候,就会找到当前 burn 的这个 ID 的信息,而对应的时间戳信息就变成了 burn 的时间,实际上应该是 prevOwnership.startTimestamp 的时间才对。
特性相关的代码就是这些,只要能够对主要逻辑理解清楚,看代码是很轻松的。
在代码中,有一个小细节需要注意一下,在涉及计算操作的时候,相关代码都被放在了 unchecked 代码块中。这是因为,0.8.0 版本之后,Solidity 编译器自带了溢出检查,也就是说,在老版本中需要使用 SafeMath 库来避免溢出错误,而新版本中编译器自带了这个功能,无需使用 SafeMath。但是代价就是编译器需要做额外校验,这些操作会消耗更多 Gas。那么如果可以确保某段代码不会产生溢出错误,我们就可以将代码放在 unchecked 中,从而节省 Gas。(参考)
我们来测试一下 ERC721 和 ERC721A 两个版本的 Gas 耗费情况。(使用 ERC721A 文档中给出的示例代码进行测试)
标准 ERC721 测试代码:
pragma solidity ^0.8.4;
// import "erc721a/contracts/ERC721A.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract Azuki is ERC721 {
constructor() ERC721("Azuki", "AZUKI") {}
function mint(uint256 quantity) external payable {
unchecked {
for (uint i = 0; i < quantity; ++i) {
// 注意在标准ERC721合约中,_safeMint的第二个参数是tokenID
// 也就是说标准ERC721合约mint的时候可以指定tokenID
// 这里这种写法只是测试用,且只有第一次mint可以成功
_safeMint(msg.sender, i);
}
}
}
}
我们一次性 mint 10个 NFT,查看 Gas 用量:
消耗了接近 30 万的 Gas。再来看看 ERC721A 的代码:
pragma solidity ^0.8.4;
import "./ERC721A.sol";
contract Azuki is ERC721A {
constructor() ERC721A("Azuki", "AZUKI") {}
function mint(uint256 quantity) external payable {
// 注意这里safeMint的第二个参数是mint的数量
_safeMint(msg.sender, quantity);
}
}
同样一次性 mint 10 个NFT,相应的 Gas 用量:
只花费了将近 11 万的 Gas,节省了将近三分之二的 Gas。
ERC721A 相比标准 ERC721 在批量 mint 方面确实能够节省很多 Gas,不过优点也就仅限于这里,如果 NFT 合约对批量 mint 没有需求,那么其实也是没有必要使用 ERC721A 的。但是这种敢于对业内标准做出挑战的做法,我觉得还是很值得学习的,只有这样,行业才能不断向前发展。