“柏林” 硬分叉将在 4 月 15 日激活,该硬分叉所包含 EIP 中的两个(EIP-2929 和 EIP-2930)都会影响事务的 Gas 开销。本文会解释 “柏林” 激活之前,一些操作码的 Gas 消耗量是如何计算的,而 EIP-2929 对此有何影响,以及,2930 引入的访问清单(Access List)功能应如何使用。
摘要
这篇文章很长,你要是只想知道结论,看完这部分就可以把网页关掉了:
-
柏林硬分叉改变了某些操作码的 Gas 开销。如果你在自己的应用中硬编码了一些操作可使用的 Gas 数量,这些操作可能会卡死。如果真的出现了这种情况,而你的智能合约又是没法升级的,用户就需要使用 “访问清单” 功能来使用你的应用。
-
访问清单功能可略微减少 Gas 开销,但有些时候也可能会提高总的 Gas 消耗量。
-
geth 客户端引入了一种新的 RPC 方法,叫做
eth_createAccessList
来简化访问清单的生成。
“柏林” 升级以前的 Gas 开销
EVM 所执行的每一个操作码都有一个对应的 Gas 消耗量。大部分操作码的消耗量都是固定的:
PUSH1
总是消耗 3 gas,而
MUL
消耗 5 gas,等等。有一些操作码的消耗量是可变的:举个例子,
SHA3
操作码的开销由输入值的长度决定。
我们先了解
SLOAD
和
SSTORE
操作码,因为这两个操作码受 “柏林” 影响最大。后面我们会再谈谈那些以地址为目标的操作,比如所有的
EXT*
类操作码和
CALL*
类操作码,因为它们的 Gas 开销也被改变了。
“柏林” 以前的
SLOAD
在 EIP-2929 实施前,
SLOAD
开销的计算方式很简单:总是消耗 800 gas。所以,也没啥可展开的。
“柏林” 以前的
SSTORE
要讲到 Gas 消耗量的计算,
SSTORE
操作码可能是最复杂的了。因为消耗多少取决于该存储项槽当前的值、要写入的新值、该存储项是否已经修改过。我们只会分析少数几种场景,了解个大概。如果你想了解更多,请阅读本文末尾所附的 EIP 链接。
-
如果存储项的值从 0 改为 1(或者任意非零的值),Gas 消耗量是 20000
-
如果存储项的值从 1 改为 2(或者任意非零的值),Gas 消耗量是 5000
-
如果存储项的值从 1(或任意非零的值) 改为 0,消耗量也是 5000,但你会在事务执行结束后获得 gas 补贴。我们这里也不讨论 gas 返还机制,因为它不会受到柏林的影响
-
在一笔事务中,如果存储项已不是第一次修改,则后续每一次
SSTORE
都消耗 800 gas
细节在这里并不重要,重要的是,
SSTORE
是昂贵的,具体消耗多少 gas 则依赖于多个因素。
EIP-2929 之后的 Gas 消耗量
EIP-2929 改变了所有这些数值。但在展开之前,我们要先谈谈该 EIP 引入的一个重要概念:
被访问过的地址
和
被访问过的存储项的键(storage key)
。
当一个地址或者一个存储项的键,在一笔事务中被 “使用过” 之后,在该笔交易余下的执行过程中,这个地址(或者这个键)都会被当成 “已被访问过的”。举个例子,如果你在一笔事务中
CALL
(调用)另一个合约,那么该合约的地址就会被标记为 “访问过的”。类似地,如果你
SLOAD
或者
SSTORE
过一些存储项槽 ,在该笔事务余下的执行过程里,这些槽也会被当成已经访问过的。到底用的哪个操作码是没有关系的,即使你只
SLOAD
过某个槽,接下来使用
SSTORE
时该槽也会被当成已访问过的。
注意:存储项的键是 “内在于” 某些地址中的,一如该 EIP 所解释的:
执行事务时,保持一个集合:
accessed_addresses: Set[Address]
以及
accessed_storage_keys: Set[Tuple[Address, Bytes32]]
也就是说,当我们说某个存储槽已被访问过了,我们的实际意思是:
(address, storageKey)
已被访问过了。
搞清楚了这个概念,我们来谈谈新的 Gas 消耗量计算模式。
“柏林” 以后的
SLOAD
升级前,
SLOAD
的 Gas 消耗量是固定的 800。但升级后,Gas 消耗量要看这个存储槽是否已经被访问过。还没访问过的,消耗量就是 2100 gas;访问过的,就是 100 gas。所以,如果某个存储项槽已经在 “已访问过的存储项键` 的集合里了,就可以省掉 2000 gas。
“柏林” 以后的
SSTORE
我们逐个逐个对比下,在 EIP-2929 实施后,上面的几个例子会发生什么样的变化:
-
如果存储项的值从 0 改为 1(或者任意非零的值),Gas 消耗量是 20000
-
如果该存储项键还未访问过,消耗 22100 gas
-
-
如果存储项的值从 1 改为 2(或者任意非零的值),Gas 消耗量是 5000
-
如果存储项的值从 1(或任意非零的值) 改为 0,消耗量保持不变,gas 返还机制也不变
-
在一笔事务中,如果存储项已不是第一次修改,则后续每一次
SSTORE
都消耗 100 gas
由此可见,如果某个槽此前已访问过,则对它的第一次
SSTORE
操作会节约 2100 gas(相比于从未访问过)。
汇总一下
上面的文字实在啰嗦,我们就直接做一张表,把上面提到的值都汇总一下:
操作码
|
“柏林” 前
|
“柏林” 后
|
|
|
|
未访问过
|
访问过
|
SLOAD
|
800
|
2100
|
100
|
SSTORE from 0 to 1
|
20000
|
22100
|
20000
|
SSTORE from 1 to 2
|
5000
|
5000
|
2900
|
SLOAD + SSTORE*
|
5800
|
5000
|
3000
|
SSTORE* + SLOAD
|
5800
|
5100
|
3000
|
SSTORE 一个已经被写过的槽
|
800
|
100
|
100
|
*从一个非零值改为另一个非零值,就像第三行所示的那样
|
|
|
|
注意看最后一行:此时已不再需要区分它到底有没有被访问过,因为,如果此前已写入,则必定已被访问过。
EIP-2930:可选 “访问清单” 的事务类型
另一个 “柏林” 升级包含的 EIP 是 2930。该 EIP 加入了一种新的类型的事务,可以在事务的负载中包含一个 “访问清单”,意思是,你可以在事务执行前就声明哪些地址和存储槽应被认为是 “访问过的”。举个例子,对一个未访问过的槽执行
SLOAD
需要耗费 2100 gas,但如果该存储槽被包含在了事务的 “访问清单” 中,则操作的消耗量机会降为 100 gas。
但如果只要地址和槽被当成 “已访问过的” 就可以降低操作的 Gas 消耗量;而访问清单可以把地址和槽标记为 “已访问过的”;那岂不是说我们可以把这些东西都放在访问清单中,来获得 Gas 消耗量的减免?真棒,天赐 Gas!
额,并不完全如此,因为你每添加一个地址或存储项键,都要支付额外的 Gas。
举个例子。假如我们要向合约
A
发送了一条事务。我们编写了一条这样的访问清单:
accessList: [{
address: "",
storageKeys: [
"0x0000000000000000000000000000000000000000000000000000000000000000"
]
}]
如果我们发送了一条带有这条访问清单的事务,而使用 0x0
存储槽的第一个操作码就是 SLOAD
,则 Gas 消耗量会是 100 而非 2100,也就是减免了 2000 gas。但是,在访问列表中声明一个存储项键需要额外支付 1900 gas,所以我们只节约了 100 gas。(如果对该存储槽的第一个操作是 SSTROE
,我们在单个操作中就省下了 2100 gas,也就是总共省下了 200 gas,因为访问清单本身需要消耗 gas)。
这是不是说,每次使用访问清单我们都能节省 gas 呢?很遗憾,也不是,因为在访问清单中填入地址也需要支付 gas。(也就是我们示例中的
"
"
)
访问过的地址
迄今为止,我们只讨论了
SLOAD
和
SSTORE
操作码,但 “柏林” 升级还改变了别的操作码。举个例子,
CALL
操作码原来的 Gas 消耗量为固定的 700,但 2929 实施后,如果所调用的地址不在访问清单中,消耗量将提高到 2600;如果在,则降低为 100。而且,就像访问过的存储键一样,到底哪个操作码访问过那个地址并不重要(比如,如果用户最先调用的是
EXTCODESIZE
,这一个操作的消耗量是 2600,但后续的调用,
只要是对同一个地址的
,无论是
EXTCODESIZE
、
CALL
还是
STATICCALL
,都只消耗 100 gas。
那个这个设计对带有访问清单的事务有何影响?假设我们向合约 A 发送一条交易,而合约 A 调用了合约 B,而我们在访问清单中写入这样的内容:
accessList: [{ address: "", storageKeys: [] }]
我们首先需要为在这条事务的访问清单中加入这个地址支付 2400 gas,但对 B 使用的第一个操作码就只需要消耗 100 gas 而不是 2600 gas,这就剩下了 100 gas。如果 B 也需要使用其存储项,我们又知道它将使用哪个键,我们也可以把这些键包含在访问列表中,然后为每个键的操作省下 100 或 200 gas(取决于第一个操作码是 SLOAD
还是 SSTORE
)。
但为啥我们要加多一个合约来举例子?我们不是可以这样写吗?
accessList: [
{address: "", storageKeys: []},
{address: "", storageKeys: []},
]
你当然可以这样做,但不值得,因为 EIP-2929 指明了你一开始调用的合约(也即是 tx.to
的目的地)必定会被包含在 accessed_addresses
列表中,所以你就是额外花了 2400 gas,什么好处都没得到。
accessList: [{
address: "",
storageKeys: [
"0x0000000000000000000000000000000000000000000000000000000000000000"
]
}]
这样做其实是浪费,除非你在里面加多几个存储项键。如果我们假设所有的存储项键的第一个操作都是 SLOAD
,那你要至少 24 个键,才能赚回来。
而且,如你所见,自己一五一十地分析这些因素、手动生成访问清单,显然是极其繁琐而令人崩溃的事。好在,还有更好的办法。
eth_createAccessList
RPC 方法
Geth 客户端(从 1.10.2)开始将包含一个新的
eth_createAccessList
RPC 方法,你可以用它来生成访问清单,就像使用
eth_estimateGas
一样,只不过返回的不是 Gas 消耗量估计,而是形如这样的数据:
{
"accessList": [
{
"address": "0xb0ee076d7779a6ce152283f009f4c32b5f88756c",
"storageKeys": [
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0x0000000000000000000000000000000000000000000000000000000000000001"
]
}
],
"gasUsed": "0x8496"
}
也就是告诉你一笔事务将会用到的地址和存储项键的清单,以及,假定纳入这份访问清单 将耗用多少 gas。跟 eth_estimateGas
一样,这也是估计出来的,该笔事务真正上链时,会访问到哪些数据仍有可能改变。但是,再说一遍,这绝不意味着你只要使用了访问清单,所用的 Gas 就会比不用清单更少!
我估计随着时间推移,我们会越来越知道怎么利用这个功能,但我个人估计,方法的伪代码形式会像这样:
let gasEstimation = estimateGas(tx)
let { accessList, gasUsed } = createAccessList(tx)
if (gasUsed > gasEstimation) {
delete accessList[tx.to]
}
tx.accessList = accessList;
sendTransaction(tx)