# ERC721A 特性详解

By [xyyme.eth](https://paragraph.com/@xyyme) · 2022-03-14

---

Azuki 项目方推出了一个新的 ERC721 标准，名叫 ERC721A，主要是对一次 mint 多个 NFT 的时候做了 Gas 优化。这篇文章就来看看 ERC721A 到底有什么神奇之处。

### 图示

我们先用图示来说明 ERC721A 和标准的 ERC721 有什么区别。

![ERC721 数据结构](https://storage.googleapis.com/papyrus_images/e664192c1593c12ca3688bb1207fe54d7df3d1554d26334ee8978222981cfdba.png)

ERC721 数据结构

标准的 ERC721，数据结构中对于每一个 tokenID，都记录了其 owner 相关信息，这样做的好处是逻辑清晰。但同时也带来一个问题就是数据冗余，一般批量 mint 的时候，连续几个 tokenID 的 owner 都是同一个地址，那必然在每一次设置的时候会消耗 Gas，那么有没有办法对这里进行优化呢。我们来看看 ERC721A 的做法：

![ERC721A 数据结构](https://storage.googleapis.com/papyrus_images/d075727d6be3f6c0aa1d0f07a570d489af648dd07bed8ea67f852d353d7ac572.png)

ERC721A 数据结构

在 ERC721A 的实现中，如果某一段连续的 tokenID 都被同一个用户拥有，那么只在第一个位置上记录相关信息，这样就会省去在接下来的位置上设置信息而导致的 Gas 消耗。

那么如果我们想要查询某个 ID 的 owner 是谁应该怎么操作呢。

![查找ID相关的信息](https://storage.googleapis.com/papyrus_images/b9eb9fe0d2440705cbec5d116f3e8282e8f53234010138dca2846dc67277fe01.png)

查找ID相关的信息

如上图所示，如果我们想要查找 ID 为 N 的 NFT，直接查询到当前 owner 是 Alice。如果我们要查询 N + 2 的 NFT，此时相应的数据为空，那么就向前查找，直到找到第一个非空的数据位，就是对应的 owner 信息。

我们再来考虑一个场景，下图中，Alice 拥有从 N 到 N + 4 这些 ID

![](https://storage.googleapis.com/papyrus_images/6fd4be08b4599f47c03223d79ee1bb3d9298184555b6e0f1867e7998d5de1347.png)

如果 Alice 想把第 N + 2 个 NFT 送给 Cindy，那么结构就会变成

![](https://storage.googleapis.com/papyrus_images/5e50a14acdbb732235b643a0784a7dda8f9ca7ef28615a0bc574e20a40a066cd.png)

但是这样就会带来一个问题，当我们想要查询 N + 3 的 owner 时，结果为空。我们按照前面的做法，向前查找第一个不为空的数据时，找到了 N + 2 的 Cindy，也就是说 N + 3 的 owner 现在变成了 Cindy！（N + 4 也是）

那么该如何解决这个问题呢，其实很简单，当发生转账行为时，将当前 ID 的后一个 ID 相关信息设置为转出人的信息。也就是说，再次显式设置一次 owner 信息：

![](https://storage.googleapis.com/papyrus_images/dceeab742546a1276045ae987b9f5e9b1583a9103d3cb9fe87d5c8d72887d28a.png)

将 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。（[参考](https://ethereum.stackexchange.com/questions/113221/what-is-the-purpose-of-unchecked-in-solidity)）

### 实践

我们来测试一下 ERC721 和 ERC721A 两个版本的 Gas 耗费情况。（使用 [ERC721A 文档](https://github.com/chiru-labs/ERC721A#usage)中给出的示例代码进行测试）

标准 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](https://storage.googleapis.com/papyrus_images/08feeade50007195277edcd169fee1c131ef0c5549c94f1d6c689dff3120029a.png)

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](https://storage.googleapis.com/papyrus_images/e8e6f85a5838b52cc239e543e04decc2f5375b1378f08d5aa23554850f3e593c.png)

ERC721A

只花费了将近 11 万的 Gas，节省了将近三分之二的 Gas。

### 总结

ERC721A 相比标准 ERC721 在批量 mint 方面确实能够节省很多 Gas，不过优点也就仅限于这里，如果 NFT 合约对批量 mint 没有需求，那么其实也是没有必要使用 ERC721A 的。但是这种敢于对业内标准做出挑战的做法，我觉得还是很值得学习的，只有这样，行业才能不断向前发展。

### 参考

[

GitHub - chiru-labs/ERC721A: https://ERC721A.org
------------------------------------------------

https://ERC721A.org. Contribute to chiru-labs/ERC721A development by creating an account on GitHub.

https://github.com

![](https://storage.googleapis.com/papyrus_images/68a594a3fb17c19a7fb3d9d98454647e46e1d62880be45655ca8598c0526bc6c.png)

](https://github.com/chiru-labs/ERC721A)

[

What 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 additional

https://ethereum.stackexchange.com

![](https://storage.googleapis.com/papyrus_images/dc7b2a51adecb9025c3c259ca1207f5f2fc8ebc314cfd83fd79e6d0cba01a644.png)

](https://ethereum.stackexchange.com/questions/113221/what-is-the-purpose-of-unchecked-in-solidity)

---

*Originally published on [xyyme.eth](https://paragraph.com/@xyyme/erc721a)*
