# ERC721A 特性详解 **Published by:** [xyyme.eth](https://paragraph.com/@xyyme/) **Published on:** 2022-03-14 **URL:** https://paragraph.com/@xyyme/erc721a ## Content Azuki 项目方推出了一个新的 ERC721 标准,名叫 ERC721A,主要是对一次 mint 多个 NFT 的时候做了 Gas 优化。这篇文章就来看看 ERC721A 到底有什么神奇之处。图示我们先用图示来说明 ERC721A 和标准的 ERC721 有什么区别。ERC721 数据结构标准的 ERC721,数据结构中对于每一个 tokenID,都记录了其 owner 相关信息,这样做的好处是逻辑清晰。但同时也带来一个问题就是数据冗余,一般批量 mint 的时候,连续几个 tokenID 的 owner 都是同一个地址,那必然在每一次设置的时候会消耗 Gas,那么有没有办法对这里进行优化呢。我们来看看 ERC721A 的做法:ERC721A 数据结构在 ERC721A 的实现中,如果某一段连续的 tokenID 都被同一个用户拥有,那么只在第一个位置上记录相关信息,这样就会省去在接下来的位置上设置信息而导致的 Gas 消耗。 那么如果我们想要查询某个 ID 的 owner 是谁应该怎么操作呢。查找ID相关的信息如上图所示,如果我们想要查找 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 用量:ERC721消耗了接近 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 用量:ERC721A只花费了将近 11 万的 Gas,节省了将近三分之二的 Gas。总结ERC721A 相比标准 ERC721 在批量 mint 方面确实能够节省很多 Gas,不过优点也就仅限于这里,如果 NFT 合约对批量 mint 没有需求,那么其实也是没有必要使用 ERC721A 的。但是这种敢于对业内标准做出挑战的做法,我觉得还是很值得学习的,只有这样,行业才能不断向前发展。参考GitHub - chiru-labs/ERC721A: https://ERC721A.orghttps://ERC721A.org. Contribute to chiru-labs/ERC721A development by creating an account on GitHub.https://github.comWhat is the purpose of "unchecked" in Solidity?According to Solidity documentation: Prior to Solidity 0.8.0, arithmetic operations would always wrap in case of under- or overflow leading to widespread use of libraries that introduce additionalhttps://ethereum.stackexchange.com ## Publication Information - [xyyme.eth](https://paragraph.com/@xyyme/): Publication homepage - [All Posts](https://paragraph.com/@xyyme/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@xyyme): Subscribe to updates - [Twitter](https://twitter.com/xyymeeth): Follow on Twitter