背景
最近偶然研究到 ERC721A 在看了关于这个协议的 简单介绍 后,感觉里面的数据不大合理,于是自己动手调研一下,看看这个协议在 gas 节省上的效果究竟如何。相关资料查看附录。
正文
既然是研究 NFT 的 gas 开销,那么就主要从 NFT 最常见的两个操作来分析:mint 和 transfer
需要先提前了解一下各个 常见操作的 gas 开销 及个别特殊操作的 gas 细节计算
Mint
ERC721A
截取主要消耗 gas 的 mint 代码:
1 | function _safeMint( address to, uint256 quantity, bytes memory _data ) internal { |
拆解各行涉及的主要 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 | function _mint(address to, uint256 tokenId) internal virtual { |
行数 | 操作 | 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 | function _transfer( address from, address to, uint256 tokenId ) private { |
行数 | 操作 | 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 | function _transfer( address from, address to, uint256 tokenId ) internal virtual { |
行数 | 操作 | 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 | /** |
这个函数的官方注释也说得很清楚了,这是一个 O(n) 复杂度的算法,最糟糕的情况下,需要遍历本合约发行的所有的 NFT,这非常吓人,几乎不可用(无法被其他合约调用)。
而 ERC721 的写法(随便找的一个 合约):
1 | /** |
这个看起来确实很简单,就只是两次 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 的开销计算则比较复杂,这里就先不细究。