当前位置:   article > 正文

NFT合约分析:ERC721A

erc721a

概述

读者可前往我的博客获得更好的阅读体验。

本文主要介绍标准NFT实现的一个变体,即ERC721A合约实现的相关细节。ERC721A是由著名NFT系列Azuki提出,该系列NFT是著名的蓝筹NFT。本文主要聚焦于Azuki提出的ERC721A合约的代码细节分析。

与传统的ERC721实现相比,ERC721A在批量铸造(batch mint)方面具有显著的gas优势,这得益于ERC721A的惰性初始化方面的设计。关于ERC721A与普通ERC721实现的对比,我们将会在下文展开说明。

本文要求读者具有基础的solidity知识,希望读者对标准ERC721有所了解。

读者可在阅读本文前,酌情阅读以下参考材料:

本文基于目前的最新版本(4.2.3)合约代码进行分析。

ERC721实现

由于下文涉及到ERC721AERC721的技术对比,考虑到部分读者可以对ERC721合约实现并不清楚,本节简要的介绍ERC721正常实现的铸造功能,本节主要基于solmate的实现版本。

solmate实现都较为短小精悍且经过gas优化,我个人较为推崇。solmateERC721实现仅有 231 行,读者可自行阅读。

solmate合约中,我们可以看到核心数据结构为:

mapping(uint256 => address) internal _ownerOf;
mapping(address => uint256) internal _balanceOf;
  • 1
  • 2

其中,各映射功能如下:

  • _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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

通过此函数,我们更新了_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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

由于对于每个tokenId都维护有一个mapping映射,所以转账逻辑实现也较为简单。

总体来看,对于每一个NFT,在solmate实现的智能合约中,都维持有以下两个映射:

  • mapping(uint256 => address) internal _ownerOf; 标识NFT的拥有者
  • mapping(uint256 => address) public getApproved; 记录NFT的授权情况

优势

在上一节中,我们介绍了常规NFT实现的基本情况,正如上文所述,常规实现在批量mint铸造阶段会消耗大量gas。为了解决这一问题,ERC721A引入惰性初始化机制。简单来说,在批量铸造时,不再记录tokenId与用户地址的映射关系,而是记录起始tokenId和数量与用户的映射关系。在本节中,我们不对此实现的技术细节进行分析,我们会在本文稍后部分对此进行讨论。

在批量铸造阶段,ERC721AOpenZeppelin实现的对比如下:

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往往是连续的,如下图:

ERC721A 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;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

与其他简单参数相比,我们主要关注复杂的参数:

  1. _packedOwnerships 类似常规NFT实现中的_ownerOf,我们通过此映射查询某 tokenID 的拥有者,但此结构是打包方式的,即我们并
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/菜鸟追梦旅行/article/detail/238344
推荐阅读
相关标签
  

闽ICP备14008679号