xyyme.eth

Posted on Mar 14, 2022Read on Mirror.xyz

ERC721A 特性详解

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 的。但是这种敢于对业内标准做出挑战的做法,我觉得还是很值得学习的,只有这样,行业才能不断向前发展。

参考

https://github.com/chiru-labs/ERC721A

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