EIP-2929: 状态访问操作码的 Gas 成本增加
Authors | Vitalik Buterin (@vbuterin), Martin Swende (@holiman) |
---|---|
Created | 2020-09-01 |
Table of Contents
简单总结
增加 SLOAD
、*CALL
、BALANCE
、EXT*
和 SELFDESTRUCT
在事务中首次使用时的 gas 成本。
摘要
将 SLOAD
(0x54
) 的 gas 成本增加到 2100,将 *CALL
操作码系列 (0xf1
、f2
、f4
、fA
)、BALANCE
0x31
和 EXT*
操作码系列 (0x3b
、0x3c
、0x3f
) 增加到 2600。豁免 (i) 预编译,以及 (ii) 已在同一事务中访问过的地址和存储槽,这些地址和存储槽的 gas 成本会降低。此外,改革 SSTORE
计量和 SELFDESTRUCT
,以确保这些操作码中固有的“事实上的存储加载”得到正确的定价。
动机
通常,操作码的 gas 成本的主要功能是估计处理该操作码所需的时间,目标是使 gas 限制对应于处理一个区块所需的时间限制。然而,访问存储的操作码(SLOAD
,以及 *CALL
、BALANCE
和 EXT*
操作码)的历史价格一直偏低。在 2016 年的上海 DoS 攻击中,一旦最严重的客户端错误得到修复,攻击者使用的更持久的成功策略之一就是简单地发送访问或调用大量帐户的交易。
Gas 成本有所增加以缓解这种情况,但最新数据表明,gas 成本增加得不够。引用 https://arxiv.org/pdf/1909.07220.pdf:
尽管就其本身而言,这个问题可能看起来是良性的,但
EXTCODESIZE
迫使客户端在磁盘上搜索合约,从而导致 IO 繁重的交易。在我们的硬件上重放 以太坊 历史记录时,恶意交易的执行时间约为 20 到 80 秒,而普通交易的执行时间仅为几毫秒
此建议的 EIP 将这些操作码的成本增加约 3 倍,从而将最坏情况下的处理时间减少到约 7-27 秒。改进数据库布局(涉及重新设计客户端以直接读取存储,而不是通过 Merkle 树跳转)将进一步减少这种情况,尽管这些技术可能需要很长时间才能完全推广,并且即使使用此类技术,访问存储的 IO 开销仍然很大。
此 EIP 的第二个好处是,它还执行了使 以太坊 中可接受的无状态见证大小所需的大部分工作。假设切换到二进制 trie,不包括代码大小的理论最大见证大小(因此是“大部分工作”而不是“全部”)将从 (12500000 gas limit) / (700 gas per BALANCE) * (800 witness bytes per BALANCE) ~= 14.3M bytes
减少到 12500000 / 2600 * 800 ~= 3.85M bytes
。当实现代码 Merklization 时,可以更改代码访问的定价。
在更远的将来,在 SNARK/STARK 见证的情况下,也有类似的好处。Starkware 的最新数据表明,他们能够在消费类台式机上每秒证明 10000 个 Rescue 哈希;假设每个 Merkle 分支有 25 个哈希,并且一个区块中充满了状态访问,目前这意味着生成见证需要 12500000 / 700 * 25 / 10000 ~= 44.64
秒,但在 EIP 之后,这将减少到 12500000 / 2500 * 25 / 10000 ~= 12.5
秒,这意味着一台台式计算机能够在任何条件下按时生成见证。STARK 证明方面的未来收益可以用于 (i) 使用更昂贵但更强大的哈希函数或 (ii) 进一步减少证明时间,从而减少延迟并因此改善依赖此类见证的无状态客户端的用户体验。
规范
参数
常量 | 值 |
---|---|
FORK_BLOCK |
12244000 |
COLD_SLOAD_COST |
2100 |
COLD_ACCOUNT_ACCESS_COST |
2600 |
WARM_STORAGE_READ_COST |
100 |
对于 block.number >= FORK_BLOCK
的区块,以下更改适用。
执行交易时,维护一个集合 accessed_addresses: Set[Address]
和 accessed_storage_keys: Set[Tuple[Address, Bytes32]]
。
这些集合是事务上下文范围的,其实现方式与其他事务范围的构造(例如自毁列表和全局 refund
计数器)相同。特别是,如果作用域恢复,则访问列表应处于进入该作用域之前的状态。
当事务执行开始时,
accessed_storage_keys
初始化为空,并且accessed_addresses
初始化为包括tx.sender
、tx.to
(如果是合约创建交易,则是正在创建的地址)- 以及所有预编译的集合。
存储读取变更
当一个地址是 (EXTCODESIZE
(0x3B
)、EXTCODECOPY
(0x3C
)、EXTCODEHASH
(0x3F
) 或 BALANCE
(0x31
)) 操作码的目标,或者是 (CALL
(0xF1
)、CALLCODE
(0xF2
)、DELEGATECALL
(0xF4
)、STATICCALL
(0xFA
)) 操作码的目标时,gas 成本的计算方式如下:
- 如果目标不在
accessed_addresses
中,则收取COLD_ACCOUNT_ACCESS_COST
gas,并将该地址添加到accessed_addresses
。 - 否则,收取
WARM_STORAGE_READ_COST
gas。
在所有情况下,gas 成本都会在调用操作码时收取,并且会更新 map。
当调用 CREATE
或 CREATE2
操作码时,立即(即,在完成检查以确定地址是否未声明之前)将正在创建的地址添加到 accessed_addresses
,但 CREATE
和 CREATE2
的 gas 成本不变。
澄清:如果 CREATE
/CREATE2
操作稍后失败,例如在执行 initcode
期间或没有足够的 gas 来将代码存储在状态中,则合约本身的 address
仍保留在 access_addresses
中(但内部作用域中进行的任何添加都会恢复)。
对于 SLOAD
,如果 (address, storage_key)
对(其中 address
是正在读取其存储的合约的地址)尚未在 accessed_storage_keys
中,则收取 COLD_SLOAD_COST
gas 并将该对添加到 accessed_storage_keys
。如果该对已在 accessed_storage_keys
中,则收取 WARM_STORAGE_READ_COST
gas。
注意:对于 call-variants,100
/2600
成本会立即应用(就像此 EIP 之前收取 700
的方式一样),即:在计算进入调用的 63/64ths
之前。
注意 2:目前没有办法对“冷账户”执行“冷 sload 读取/写入”,仅仅因为为了读取/写入 slot
,执行必须已经在 account
内部。因此,截至此 EIP,冷账户上的冷存储读取/写入的行为是未定义的。任何未来的 EIP,如果
建议添加“远程读取/写入”,则需要定义该更改的定价行为。
SSTORE 变更
调用 SSTORE
时,检查 (address, storage_key)
对是否在 accessed_storage_keys
中。如果不在,则额外收取 COLD_SLOAD_COST
gas,并将该对添加到 accessed_storage_keys
。此外,按如下方式修改 EIP-2200 中定义的参数:
参数 | 旧值 | 新值 |
---|---|---|
SLOAD_GAS |
800 | = WARM_STORAGE_READ_COST |
SSTORE_RESET_GAS |
5000 | 5000 - COLD_SLOAD_COST |
EIP 2200 中定义的其他参数不变。
注意:常量 SLOAD_GAS
在 EIP 2200 中的多个位置使用,例如 SSTORE_SET_GAS - SLOAD_GAS
。使用复合定义的实现必须确保也更新这些定义。
SELFDESTRUCT 变更
如果 SELFDESTRUCT
的 ETH 接收者不在 accessed_addresses
中(无论发送的金额是否为非零),则在现有 gas 成本的基础上额外收取 COLD_ACCOUNT_ACCESS_COST
,并将 ETH 接收者添加到集合中。
注意:如果接收者已经处于 warm 状态,SELFDESTRUCT
不会收取 WARM_STORAGE_READ_COST
,这与其他 call-variants 的工作方式不同。其背后的原因是保持较小的更改,SELFDESTRUCT
已经花费 5K
,如果多次调用,则为 no-op。
理由
操作码成本与按见证数据字节收费
更改 gas 成本以反映见证大小的自然替代方法是按见证数据的字节收费。但是,这将需要更长的时间才能实现,从而阻碍了提供短期安全缓解的目标。此外,忠实地遵循该路径将导致接触合约代码的交易的 gas 成本非常高,因为需要为所有 24576 个合约代码字节收费;这将给开发人员带来不可接受的高负担。最好等待代码 merklization 开始尝试正确地计算访问各个代码块的 gas 成本;从短期 DoS 预防的角度来看,从磁盘访问 24 kB 的成本并不比从磁盘访问 32 字节的成本高多少,因此没有必要担心代码大小。
添加 accessed_addresses / accessed_storage_keys 集合
添加已访问的帐户和存储槽集合是为了避免不必要地收取可以缓存(并且在所有高性能实现中都已经缓存)的内容的费用。此外,它消除了当前不希望的现状,即进行自我调用或调用预编译的成本过于昂贵,并启用了合约中断缓解,该缓解涉及预取一些存储密钥,从而使未来的执行仍然获得预期的 gas 量。
SSTORE gas 成本变更
需要更改 SSTORE 以避免 DoS 攻击的可能性,该攻击“戳”一个随机选择的零存储槽,以 800 gas 的成本将其从 0 更改为 0,但需要事实上的存储加载。SSTORE_RESET_GAS
的减少确保了 SSTORE 的总成本(现在需要支付 COLD_SLOAD_COST
)保持不变。此外,请注意,执行 SLOAD
后跟 SSTORE
的应用程序(例如 storage_variable += x
)实际上会更便宜!
仅以最小的方式更改 SSTORE 记帐
SSTORE gas 成本继续使用 Wei Tang 的原始/当前/新方法,而不是被重新设计为使用脏 map,因为 Wei Tang 的方法正确地计算了更改存储的实际成本,而这些成本仅关心当前值与最终值,而不关心中间值。
在此提议下,平均应用程序的 gas 消耗量将如何增加?
来自见证大小的粗略分析
我们可以查看 Alexey Akhunov 之前的作品 以获取有关平均情况区块的数据。总而言之,平均区块的见证大小约为 1000 kB,其中约 750 kB 是 Merkle 证明,而不是代码。假设每个 Merkle 分支 2000 字节,这意味着每个区块约 375 次访问(SLOAD 的 gas 增加与字节数的比率相似,因此无需单独分析它们)。
来自 Etherscan 的每日交易数量和每日区块数量的数据显示,每个区块约有 160 个交易(参考日期:7 月 1 日),这意味着这些访问中的很大一部分只是 tx.sender
和 tx.to
,它们不包括在 gas 成本增加中,但由于重复地址,可能少于 320 个。
因此,这意味着每个区块约 50-375 次可收费的访问,并且每次访问的 gas 成本增加 1900;50 * 1900 = 95000
和 375 * 1900 = 712500
,这意味着 gas 限制需要提高约 1-6% 才能进行补偿。但是,以下因素可能会使此分析在任一方向上变得更加复杂:(i)帐户/存储密钥在多个交易中被访问,这将在见证中出现一次,但在 gas 成本增加中出现两次,以及(ii)帐户/存储密钥在同一交易中被多次访问,这会导致 gas 成本_降低_。
Goerli 分析
通过扫描 Goerli 交易可以找到更精确的分析,Martin Swende 在此处完成了此操作:https://github.com/holiman/gasreprice
结论是,平均 gas 成本增加约 2.36%。降低 gas 成本的一个主要因素是,大量合约多次低效地读取相同的存储槽,这导致此 EIP 为一些交易节省了 10% 以上的 gas 成本。
向后兼容性
这些 gas 成本的增加可能会破坏依赖于固定 gas 成本的合约;有关详细信息和我们为何期望总体风险较低以及如何根据需要进一步降低风险的论点,请参见安全注意事项部分。
测试用例
一些测试用例可以在这里找到:https://gist.github.com/holiman/174548cad102096858583c6fbbb0649a
理想情况下,我们将测试以下内容:
- SLOAD 同一个存储槽 {1, 2, 3} 次
- CALL 同一个地址 {1, 2, 3} 次
-
在子调用中 (SLOAD CALL),然后恢复,然后再次 (SLOAD CALL) 相同的(存储槽 地址) - 子调用,SLOAD,再次子调用,恢复内部子调用,SLOAD 同一个存储槽
- SSTORE 同一个存储槽 {1, 2, 3} 次,使用原始值和正在设置的值的所有零/非零组合
- SSTORE 然后 SLOAD 同一个存储槽
- 将
OP_1
然后OP_2
到同一个地址,其中OP_1
和OP_2
是 (*CALL
,EXT*
,SELFDESTRUCT
) 的所有组合 -
尝试 CALL
一个地址,但具有所有可能的失败模式(gas 不足,ETH 不足…),然后再次成功 (CALL
EXT*
) 该地址
实现
Geth 的 WIP 早期草案实现可以在这里找到:https://github.com/holiman/go-ethereum/tree/access_lists
安全注意事项
与任何 gas 成本增加的 EIP 一样,可能存在三种可能导致应用程序中断的情况:
- 合约中子调用的固定 gas 限制
- 依赖于消耗接近完整 gas 限制的合约调用的应用程序
- ETH 转移调用给被调用者的 2300 基本限制
之前已经在早期 gas 成本增加 EIP-1884 的背景下研究了这些风险。请参阅Martin Swende 之前的报告和Hubert Ritzdorf 的分析,重点关注 (1) 和 (3)。(2) 受到的分析较少,但可以争辩说它非常不可能,因为应用程序倾向于很少在交易中使用接近整个 gas 限制,并且 gas 限制最近从 1000 万提高到 1250 万。实际上,EIP-1884 确实导致少量合约因此而中断。
有两种方法可以看待这些风险。首先,我们可以注意到,截至今天,开发人员已经收到了多年的警告;关于访问存储的操作码的 gas 成本增加已经讨论了很长时间,包括向主要 dapp 开发人员发表了关于此类更改可能性的多项声明。EIP-1884 本身提供了一个重要的警钟。因此,我们可以争辩说,这次的风险将大大低于 EIP-1884。
合约中断缓解
看待风险的第二种方法是探索缓解措施。首先,accessed_addresses
和 accessed_storage_keys
map 的存在(存在于此 EIP 中,不存在于 EIP-1884 中)已经使某些情况可以恢复:在合约 A 需要向某个地址 B 发送资金的任何情况下,其中该地址接受来自任何来源的资金,但会留下依赖存储的日志,可以通过首先向 B 发送单独的调用以将其拉入缓存,然后调用 A 来恢复,因为知道由 A 触发的 B 的执行将仅对每个 SLOAD 收取 100 gas。此事实不能解决所有情况,但确实大大降低了风险。
但是,有一些方法可以进一步扩展此模式的可用性。一种可能性是添加一个 POKE
预编译,它将地址和存储密钥作为输入,并允许交易通过预先探查它们将访问的所有存储槽来尝试“拯救”卡住的合约。即使该地址仅接受来自合约的交易,并且在当前 gas 限制下在许多其他上下文中有效,这也是可行的。唯一不起作用的情况是交易调用_必须_从 EOA 直接进入特定合约,然后子调用另一个合约的情况。
另一个选择是 EIP-2930,它将具有与 POKE
类似的效果,但更通用:它也适用于 EOA -> 合约 -> 合约的情况,并且通常应适用于由于 gas 成本增加而导致的所有已知中断情况。此选项更复杂,但可以说它是访问列表用于其他用例的垫脚石(regenesis、帐户抽象、SSA 都需要访问列表)。
版权
在 CC0 下放弃版权及相关权利。
Citation
Please cite this document as:
Vitalik Buterin (@vbuterin), Martin Swende (@holiman), "EIP-2929: 状态访问操作码的 Gas 成本增加," Ethereum Improvement Proposals, no. 2929, September 2020. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-2929.