以及 EIP-2930 引入的访问清单(Access List)功能应如何使用?

原文标题:《干货 | 搞懂 “柏林” 之后的合约 Gas 开销》
撰文:Franco Victorio
翻译 & 校对:阿剑

「柏林」硬分叉已于 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 操作码的开销由输入值的长度决定。

我们先了解 SLOADSSTORE 操作码,因为这两个操作码受 “柏林” 影响最大。后面我们会再谈谈那些以地址为目标的操作,比如所有的 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

    • 若已访问过,消耗 20000 gas

  • 如果存储项的值从 1 改为 2 (或者任意非零的值),Gas 消耗量是 5000

    • 如果该存储项键还未访问过,消耗 5000 gas

    • 若已访问过,消耗 2900 gas

  • 如果存储项的值从 1 (或任意非零的值) 改为 0,消耗量保持不变,gas 返还机制也不变

  • 在一笔事务中,如果存储项已不是第一次修改,则后续每一次 SSTORE 都消耗 100 gas

由此可见,如果某个槽此前已访问过,则对它的第一次 SSTORE 操作会节约 2100 gas (相比于从未访问过)。

汇总一下

上面的文字实在啰嗦,我们就直接做一张表,把上面提到的值都汇总一下:

柏林硬分叉如何影响以太坊交易的 Gas 费开销?

注意看最后一行:此时已不再需要区分它到底有没有被访问过,因为,如果此前已写入,则必定已被访问过。

EIP-2930:可选 「访问清单」的事务类型

另一个 “柏林” 升级包含的 EIP 是 2930。该 EIP 加入了一种新的类型的事务,可以在事务的负载中包含一个 “访问清单”,意思是,你可以在事务执行前就声明哪些地址和存储槽应被认为是 “访问过的”。举个例子,对一个未访问过的槽执行 SLOAD 需要耗费 2100 gas,但如果该存储槽被包含在了事务的 “访问清单” 中,则操作的消耗量机会降为 100 gas。

但如果只要地址和槽被当成 “已访问过的” 就可以降低操作的 Gas 消耗量;而访问清单可以把地址和槽标记为 “已访问过的”;那岂不是说我们可以把这些东西都放在访问清单中,来获得 Gas 消耗量的减免?真棒,天赐 Gas!

额,并不完全如此,因为你每添加一个地址或存储项键,都要支付额外的 Gas。

举个例子。假如我们要向合约 A 发送了一条事务。我们编写了一条这样的访问清单:

柏林硬分叉如何影响以太坊交易的 Gas 费开销?

如果我们发送了一条带有这条访问清单的事务,而使用 0x0 存储槽的第一个操作码就是 SLOAD,则 Gas 消耗量会是 100 而非 2100,也就是减免了 2000 gas。但是,在访问列表中声明一个存储项键需要额外支付 1900 gas,所以我们只节约了 100 gas。(如果对该存储槽的第一个操作是 SSTROE,我们在单个操作中就省下了 2100 gas,也就是总共省下了 200 gas,因为访问清单本身需要消耗 gas)。

这是不是说,每次使用访问清单我们都能节省 gas 呢?很遗憾,也不是,因为在访问清单中填入地址也需要支付 gas。(也就是我们示例中的 ""

访问过的地址

迄今为止,我们只讨论了 SLOADSSTORE 操作码,但 “柏林” 升级还改变了别的操作码。举个例子,CALL 操作码原来的 Gas 消耗量为固定的 700,但 2929 实施后,如果所调用的地址不在访问清单中,消耗量将提高到 2600;如果在,则降低为 100。而且,就像访问过的存储键一样,到底哪个操作码访问过那个地址并不重要(比如,如果用户最先调用的是 EXTCODESIZE,这一个操作的消耗量是 2600,但后续的调用,只要是对同一个地址的,无论是 EXTCODESIZECALL 还是 STATICCALL ,都只消耗 100 gas。

那个这个设计对带有访问清单的事务有何影响?假设我们向合约 A 发送一条交易,而合约 A 调用了合约 B,而我们在访问清单中写入这样的内容:
柏林硬分叉如何影响以太坊交易的 Gas 费开销?

我们首先需要为在这条事务的访问清单中加入这个地址支付 2400 gas,但对 B 使用的第一个操作码就只需要消耗 100 gas 而不是 2600 gas,这就剩下了 100 gas。如果 B 也需要使用其存储项,我们又知道它将使用哪个键,我们也可以把这些键包含在访问列表中,然后为每个键的操作省下 100 或 200 gas (取决于第一个操作码是 SLOAD 还是 SSTORE)。

但为啥我们要加多一个合约来举例子?我们不是可以这样写吗?
柏林硬分叉如何影响以太坊交易的 Gas 费开销?

你当然可以这样做,但不值得,因为 EIP-2929 指明了你一开始调用的合约(也即是 tx.to 的目的地)必定会被包含在 accessed_addresses 列表中,所以你就是额外花了 2400 gas,什么好处都没得到。

所以,回头看我们上面举的例子:
柏林硬分叉如何影响以太坊交易的 Gas 费开销?

这样做其实是浪费,除非你在里面加多几个存储项键。如果我们假设所有的存储项键的第一个操作都是 SLOAD,那你要至少 24 个键,才能赚回来。

而且,如你所见,自己一五一十地分析这些因素、手动生成访问清单,显然是极其繁琐而令人崩溃的事。好在,还有更好的办法。

eth_createAccessList RPC 方法

Geth 客户端(从 1.10.2)开始将包含一个新的 eth_createAccessList RPC 方法,你可以用它来生成访问清单,就像使用 eth_estimateGas 一样,只不过返回的不是 Gas 消耗量估计,而是形如这样的数据:

柏林硬分叉如何影响以太坊交易的 Gas 费开销?

也就是告诉你一笔事务将会用到的地址和存储项键的清单,以及,_假定纳入这份访问清单_将耗用多少 gas。跟 eth_estimateGas 一样,这也是估计出来的,该笔事务真正上链时,会访问到哪些数据仍有可能改变。但是,再说一遍,这绝不意味着你只要使用了访问清单,所用的 Gas 就会比不用清单更少!

我估计随着时间推移,我们会越来越知道怎么利用这个功能,但我个人估计,方法的伪代码形式会像这样:
柏林硬分叉如何影响以太坊交易的 Gas 费开销?

防止合约变砖

值得提醒,访问清单功能的主要目的不是节省 Gas。如该 EIP 自身所述:

缓解由 EIP-2929 带来的合约变砖风险,因为事务可以预先指定、预先支付自身尝试范文的账户和存储槽,因此,在实际的执行中,SLOAD 和 EXT* 操作码都只会消耗 100 gas:这个值低到既足以防止 2929 打破某些合约,也可以 “解封” 被 EIP-1884 封印的合约。

原本,只要一个合约预设了执行的 Gas 开销,操作码的 Gas 消耗量变动就有可能导致它变砖。比如,如果一个合约预设另一个合约的 someFunction 只会用到 34500 gas,因此总是用 someOtherContract.someFunction{gas: 34500}() 调用那个合约,这个合约就有可能变砖。但只要你在事务中添加合适的访问清单,这个合约就还能工作。

自己验证

如果你想自己测试一下,克隆这个仓库,这里面有很多例子,可以使用 Hardhat 和 Geth 客户端来运行。请仔细阅读 README。

参考文献

  • EIP-2929 和 EIP-2930 是两个跟本文有关的 “柏林” EIP。

  • EIP-2930 依赖于 “柏林” 升级纳入的另一个 EIP:EIP-2718,也叫标准化的事务信封。

  • EIP-2929 大量参考了 EIP-2200,如果你想更深入地理解 Gas 消耗量,你应该从那里开始。

来源链接:hackmd.io