ERC721A gas 实际开销研究

背景

最近偶然研究到 ERC721A 在看了关于这个协议的 简单介绍 后,感觉里面的数据不大合理,于是自己动手调研一下,看看这个协议在 gas 节省上的效果究竟如何。相关资料查看附录。

正文

既然是研究 NFT 的 gas 开销,那么就主要从 NFT 最常见的两个操作来分析:mint 和 transfer

需要先提前了解一下各个 常见操作的 gas 开销 及个别特殊操作的 gas 细节计算

Mint

ERC721A

截取主要消耗 gas 的 mint 代码:

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
function _safeMint( address to, uint256 quantity, bytes memory _data ) internal {
uint256 startTokenId = currentIndex;
require(to != address(0), "ERC721A: mint to the zero address");
// We know if the first token in the batch doesn't exist, the other ones don't as well, because of serial ordering.
require(!_exists(startTokenId), "ERC721A: token already minted");
require(quantity <= maxBatchSize, "ERC721A: quantity to mint too high");
_beforeTokenTransfers(address(0), to, startTokenId, quantity);
AddressData memory addressData = _addressData[to];
_addressData[to] = AddressData(
addressData.balance + uint128(quantity),
addressData.numberMinted + uint128(quantity)
);
_ownerships[startTokenId] = TokenOwnership(to, uint64(block.timestamp));
uint256 updatedIndex = startTokenId;
for (uint256 i = 0; i < quantity; i++) {
emit Transfer(address(0), to, updatedIndex);
require(
_checkOnERC721Received(address(0), to, updatedIndex, _data),
"ERC721A: transfer to non ERC721Receiver implementer"
);
updatedIndex++;
}
currentIndex = updatedIndex;
_afterTokenTransfers(address(0), to, startTokenId, quantity);
}

拆解各行涉及的主要 gas 开销操作如下:

行数 操作 gas 开销
2 SLOAD 2100
6 SLOAD 2100
8 SLOAD 2100
9~12 SSTORE 20000 或 2900
13 SLOAD、SSTORE 2100、20000
16 LOG4 n * (5 * 375) = 1875 * n
23 SSTORE 2900

所以当一个账户 初次 mint 的时候总 gas 开销(不低于):

GasUsed=2100+2100+2100+20000+2100+20000+1875n+2900=51300+1875n

如果此时只 mint 1 个账户的话就是:51300+1875*1=53175

验证
  • 这笔 交易 mint 了 5 个 NFT,总计 gas 花费了 87393 ,而这笔 交易 mint 了 2 个,总计 gas 开销 80376,通过人肉检查,这两笔交易都是两个人首次 mint(符合商标上述的计算公式)。下面这个链接可以查看前 1000 笔操作的 gas 开销,这也可以做侧面验证: https://etherscan.io/vmtrace?txhash=
  • 根据 文章 里的公式: TotalGas = 21000 + InputDataFee + GasUsed - GasRefund 而 mint 了两个 NFT 的这笔 交易 的 data 是 0x4d3554c30000000000000000000000000000000000000000000000000000000000000002,根据 EIP2028 计算出 InputDataFee = 5 * 16 + 31 * 4 = 204,所以交易的 opcode 操作从 83876 - 21000 - 204 = 62672 开始(GasLimit - 21000 - InputDataFee)。见 详情
  • 根据上面的公式估算出的总 gas 开销是 51300+1875*2=55050,与正确答案 62672 的相差 12% 左右,这些差值就是其他众多细碎操作的开销,不占大头,可以在本次讨论中忽略

ERC721

1
2
3
4
5
6
7
8
9
10
function _mint(address to, uint256 tokenId) internal virtual {
require(to != address(0), "ERC721: mint to the zero address");
require(!_exists(tokenId), "ERC721: token already minted");
_beforeTokenTransfer(address(0), to, tokenId);
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(address(0), to, tokenId);

_afterTokenTransfer(address(0), to, tokenId);
}
行数 操作 gas 开销
3 SLOAD 2100
5 SLOAD、SSTORE 2100、20000 或 2900
6 SLOAD、SSTORE 2100、20000
7 LOG4 5 * 375 = 1875

所以当一个账户 初次 mint 的时候总 gas 开销: GasUsed=2100+2100+20000+2100+20000+1875=48175

这个结论跟 ERC721A 介绍 里说常规 ERC721 mint 一个 NFT 需要花费 154814 gas 的结论相差比较悬殊,这是因为那篇文章用到了一些恶心人的技巧,比如它拿带有 enumerable 功能的 ERC721 来比,那肯定人家消耗的 gas 高啊。并不是所有 NFT 都必须要带有 enumerable 功能的,它只是 ERC721 的一个可选项而已。

尽管当 mint 多个时,ERC721A 确实优势明显,但是当 mint 一个的时候却不一定。

比如这笔 ERC721 的 mint 交易 就花费不多,实测比它文章里说的少了 40%

结论

(初次 mint)当只 mint 一个的时候,ERC721 比 ERC721A 略省 gas,但是当多 mint 几个的时候,ERC721A 的优势会逐渐扩大。

mint 数量 gas(ERC721) gas(ERC721A)
1 48175 53175
2 96350 55050
3 144525 56925
n 48175 * n 51300 + 1875 * n

Transfer

ERC721A

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
function _transfer( address from, address to, uint256 tokenId ) private {
TokenOwnership memory prevOwnership = ownershipOf(tokenId);
bool isApprovedOrOwner = (_msgSender() == prevOwnership.addr
getApproved(tokenId) == _msgSender()
isApprovedForAll(prevOwnership.addr, _msgSender()));
require( isApprovedOrOwner, "ERC721A: transfer caller is not owner nor approved" );
require( prevOwnership.addr == from, "ERC721A: transfer from incorrect owner" );
require(to != address(0), "ERC721A: transfer to the zero address");
_beforeTokenTransfers(from, to, tokenId, 1);
// Clear approvals from the previous owner
_approve(address(0), tokenId, prevOwnership.addr);
_addressData[from].balance -= 1;
_addressData[to].balance += 1;
_ownerships[tokenId] = TokenOwnership(to, uint64(block.timestamp));
// If the ownership slot of tokenId+1 is not explicitly set, that means the transfer initiator owns it.
// Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls.
uint256 nextTokenId = tokenId + 1;
if (_ownerships[nextTokenId].addr == address(0)) {
if (_exists(nextTokenId)) {
_ownerships[nextTokenId] = TokenOwnership( prevOwnership.addr, prevOwnership.startTimestamp );
}
}
emit Transfer(from, to, tokenId);
_afterTokenTransfers(from, to, tokenId, 1);
}
行数 操作 gas 开销
2 SLOAD、SLOAD、SLOAD 2100、2100、2100
11 SSTORE、LOG4 100、5 * 375 = 1875
12 SLOAD、SLOAD、SSTORE 2100、100、2900
13 SLOAD、SLOAD、SSTORE 2100、100、20000 或 2900
14 SLOAD、SSTORE 100、2900
18 SLOAD、SLOAD 2100、100
20 SLOAD、SSTORE 100、20000
23 LOG4 5 * 375 = 1875

最糟糕且转给一个初次拥有这个 REC721A 的地址的情况下,本次 transfer 开销:

GasUsed=2100+2100+2100+100+2100+100+2900+2100+100+20000+100+2900+2100+100+100+20000+1875*2=40650+2100+20000=62750

最糟糕的情况指本次 transfer 需要运行第 20 行的代码

非初次的话,在第 13 行的 SSTORE 就只需要消耗 2900 gas

验证
  • 这笔 交易 就符合 最糟糕且转给一个初次拥有这个 REC721A 的地址的情况 ,它的 InputDataFee = 45*16+55*4=940,那么执行 opcode 的起始 Gas = GasLimit-21000-InputDataFee=87683-21000-940=65743,确实与 实际结果 一致
  • 这个人 有两笔几乎一样的 transfer 交易,消耗的 gas 都不一样,计算了一下差值刚好是 20000-2900=17100,符合初次和非初次的预期(第 13 行)
  • 那又如何验证 文章 里的公式是正确的呢?可以从这笔 交易 来验证,因为这笔交易操作步数少(低于 1000),所以能从 这里 看到最终剩余多少 gas: InputDataFee=46*16+54*4=952 GasUsed=192014-142644=49370 因为这笔实际的交易并不是最糟糕的情况,所以需要剔除第 19 和 20 行的 gas,那么这个实际的值 49370 和预估的值 40250 还是比较接近的。这也从侧面证明文章里的那个公式基本可信

REC721

1
2
3
4
5
6
7
8
9
10
11
12
function _transfer( address from, address to, uint256 tokenId ) internal virtual {
require(ERC721Upgradeable.ownerOf(tokenId) == from, "ERC721: transfer from incorrect owner");
require(to != address(0), "ERC721: transfer to the zero address");
_beforeTokenTransfer(from, to, tokenId);
// Clear approvals from the previous owner
_approve(address(0), tokenId);
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
_afterTokenTransfer(from, to, tokenId);
}
行数 操作 gas 开销
0 SLOAD、SLOAD、SLOAD 2100 * 3 = 6300
2 SLOAD 100
6 SSTORE、LOG4 100、5 * 375 = 1875
7 SLOAD、SSTORE 2100、2900
8 SLOAD、SSTORE 2100、20000
9 SSTORE 2900
10 LOG4 5 * 375 = 1875

需要说明的是,ERC721 的 owner 和 approval 判断是放在 transferFrom 函数里,公平起见我这里没有列出代码,但是还是计算了该有的 gas 开销的,放在上表的第 0 行位置

GasUsed=6300+100+100+2100+2900+100+20000+2900+1875*2=38250

可参考 这里

结论

在最糟糕且转给一个初次拥有这个 REC721A 的地址的情况下,ERC721 和 ERC721A 的 gas 开销对比:

transfer 数量 gas(ERC721) gas(ERC721A)
1 38250 62750
2 76500 103400
3 114750 166150
n 38250 * n (n > 0) 62750 * n - 22100 (n > 1)

ERC721Enumerable

以上对比都是基于 NFT 无 enumerable 的,如果考虑进这个功能,以 tokenOfOwnerByIndex 接口举例,ERC721A 的写法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @dev See {IERC721Enumerable-tokenOfOwnerByIndex}.
* This read function is O(collectionSize). If calling from a separate contract, be sure to test gas first.
* It may also degrade with extremely large collection sizes (e.g >> 10000), test for your use case.
*/
function tokenOfOwnerByIndex(address owner, uint256 index) public view override returns (uint256) {
require(index < balanceOf(owner), "ERC721A: owner index out of bounds");
uint256 numMintedSoFar = totalSupply();
uint256 tokenIdsIdx = 0;
address currOwnershipAddr = address(0);
for (uint256 i = 0; i < numMintedSoFar; i++) {
TokenOwnership memory ownership = _ownerships[i];
if (ownership.addr != address(0)) {
currOwnershipAddr = ownership.addr;
}
if (currOwnershipAddr == owner) {
if (tokenIdsIdx == index) {
return i;
}
tokenIdsIdx++;
}
}
revert("ERC721A: unable to get token of owner by index");
}

这个函数的官方注释也说得很清楚了,这是一个 O(n) 复杂度的算法,最糟糕的情况下,需要遍历本合约发行的所有的 NFT,这非常吓人,几乎不可用(无法被其他合约调用)。

而 ERC721 的写法(随便找的一个 合约):

1
2
3
4
5
6
7
8
9
10
/**
* @dev Gets the token ID at a given index of the tokens list of the requested owner.
* @param owner address owning the tokens list to be accessed
* @param index uint256 representing the index to be accessed of the requested tokens list
* @return uint256 token ID at the given index of the tokens list owned by the requested address
*/
function tokenOfOwnerByIndex(address owner, uint256 index) public view returns (uint256) {
require(index < balanceOf(owner), "ERC721Enumerable: owner index out of bounds");
return _ownedTokens[owner][index];
}

这个看起来确实很简单,就只是两次 SLOAD,但是它的代价已经在 mint 和 transfer 的时候付出了。属于是空间换时间的方案,但是它是可组合的(可以放心地被其他合约调用)

总结

假设 最糟糕 的情况下,总共 mint 出 n 个 (无 enumerable 功能)NFT 并将其全部 transfer 给一个 新人 ,这种场景下的 gas 合计开销为:

NFT 数量 gas(ERC721) gas(ERC721A)
1 86425 115925
2 172850 158450
3 259275 223075
n 86425 * n (n > 0) 64625 * n + 29200 (n > 1)

ERC721: 48175*n+38250*n=86425*n

ERC721A: 51300+1875*n+62350*n-22100=64625*n+29200

所以,当且仅当在用户只 mint 一个 NFT 且会将其 transfer 出去的前提下,ERC721 会比 ERC721A 省 gas,其余情况都是 ERC721A 省。

然而再结合实际的用户使用场景,绝大多数的用户都只是抱着炒作的心态来的,大部分人都只会 mint 一次,然后再找机会转手卖掉,所以对于这些人来说 ERC721A 反而会消耗更多的 gas 。

其他

关于上面表格里各个 opcode 的 gas 开销是怎么得来的,主要是参考 这篇这篇。在 solidity 里占用 gas 开销最高的莫过于跟 storage 变量相关的操作:SLOAD 和 SSTORE,而其中 SSTORE 的开销计算则比较复杂,这里就先不细究。