状态访问操作码的Gas成本增加

该EIP提案旨在提高以太坊中首次访问SLOAD*CALLBALANCEEXT*SELFDESTRUCT等操作码的Gas成本,同时对后续重复访问降低费用。此举旨在解决历史性操作码定价过低导致的DoS攻击风险,提升网络抗压能力,并为未来的无状态客户端和STARK/SNARK证明生成提供更好的经济模型。

简要概述

SLOAD*CALLBALANCEEXT*SELFDESTRUCT 在交易中首次使用时,增加其 gas 成本。

摘要

SLOAD (0x54) 的 gas 成本提高到 2100,将 *CALL 操作码家族 (0xf1f2f4fA)、BALANCE (0x31) 和 EXT* 操作码家族 (0x3b0x3c0x3f) 提高到 2600。免除 (i) 预编译合约,以及 (ii) 在同一交易中已被访问过的地址和存储槽,它们将获得降低的 gas 成本。此外,改革 SSTORE 计量和 SELFDESTRUCT,以确保这些操作码中固有的“事实存储加载”被正确计价。

动机

通常,操作码 gas 成本的主要功能是估算处理该操作码所需的时间,目标是使 gas limit 与处理区块所需的时间限制相对应。然而,存储访问操作码 (SLOAD,以及 *CALLBALANCEEXT* 操作码) 历史上一直定价过低。在 2016 年上海 DoS 攻击中,一旦最严重的客户端错误被修复,攻击者使用的一个更持久成功的策略是简单地发送访问或调用大量账户的交易。

为了缓解这种情况,gas 成本有所增加,但最近的数据表明增加得还不够。引用 https://arxiv.org/pdf/1909.07220.pdf

Although by itself, this issue might seem benign, EXTCODESIZE forces the client to search the contract ondisk, resulting in IO heavy transactions. While replaying the Ethereum history on our hardware, the malicious transactions took around 20 to 80 seconds to execute, compared to a few milliseconds for the average transactions

此 EIP 提议将这些操作码的成本提高约 $\sim 3$ 倍,将最坏情况下的处理时间减少到 $\sim 7-27$ 秒。数据库布局的改进,包括重新设计客户端以直接读取存储而不是通过 Merkle 树跳转,将进一步减少此时间,尽管这些技术可能需要很长时间才能完全推广,即使有这些技术,访问存储的 IO 开销仍将是巨大的。

此 EIP 的一个次要好处是,它还完成了使以太坊中的无状态见证大小可接受所需的大部分工作。假设切换到二叉树,不包括代码大小的理论最大见证大小(因此是“大部分工作”而不是“全部工作”)将从 $(12500000 \text{ gas limit}) / (700 \text{ gas per BALANCE}) (800 \text{ witness bytes per BALANCE}) \approx 14.3\text{M bytes}$ 减少到 $12500000 / 2600 800 \approx 3.85\text{M bytes}$。当实现代码 Merklization 时,代码访问的定价可能会改变。

在更远的将来,SNARK/STARK 见证也有类似的好处。Starkware 的最新数据显示,他们能够在消费级台式机上每秒证明 10000 个 Rescue 哈希;假设每个 Merkle 分支 25 个哈希,以及一个充满状态访问的区块,目前这将意味着生成一个见证需要 $12500000 / 700 25 / 10000 \approx 44.64$ 秒,但在此 EIP 之后,这将减少到 $12500000 / 2500 25 / 10000 \approx 12.5$ 秒,这意味着一台台式计算机将能够在任何条件下及时生成见证。STARK 证明的未来收益可以用于 (i) 使用更昂贵但更健壮的哈希函数,或 (ii) 进一步缩短证明时间,减少延迟,从而改善依赖此类见证的无状态客户端的用户体验。

规范

参数

常量
FORK_BLOCK 12244000
COLD_SLOAD_COST 2100
COLD_ACCOUNT_ACCESS_COST 2600
WARM_STORAGE_READ_COST 100

对于 $block.number \ge FORK\_BLOCK$ 的区块,应用以下更改。

执行交易时,维护一个集合 accessed_addresses: Set[Address]accessed_storage_keys: Set[Tuple[Address, Bytes32]]

这些集合在交易上下文范围内,实现方式与其他交易范围内的构造(例如自毁列表和全局 refund 计数器)相同。特别是,如果一个范围回滚,访问列表应该处于进入该范围之前的状态。

当交易执行开始时,

  • accessed_storage_keys 初始化为空,并且
  • accessed_addresses 初始化为包含
    • tx.sendertx.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 成本在操作码被调用时收取,并且映射在此时更新。 当调用 CREATECREATE2 操作码时,立即(即在检查 $address$ 是否未被声明之前)将正在创建的地址添加到 accessed_addresses,但 CREATECREATE2 的 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。

注意:对于调用变体,100/2600 的成本会立即应用(就像此 EIP 之前收取 700 一样),即:在计算进入调用可用的 $\frac{63}{64}$ 之前。

注意 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 接收者添加到该集合中。

注意:SELFDESTRUCT 在接收者已“暖”的情况下不会收取 WARM_STORAGE_READ_COST,这与其他调用变体的工作方式不同。其原因是为了保持更改较小,SELFDESTRUCT 已经花费 5K,并且如果多次调用则无效。

基本原理

操作码成本与按见证数据字节收费

反映见证大小的 gas 成本变化的自然替代路径是按见证数据的字节数收费。然而,这需要更长的时间来实现,从而阻碍了提供短期安全缓解的目标。此外,忠实地遵循该路径将导致访问合约代码的交易具有极高的 gas 成本,因为需要对所有 24576 字节的合约代码收费;这将给开发者带来不可接受的沉重负担。最好等待代码 Merklization 的实现,然后才开始尝试正确核算访问单个代码块的 gas 成本;从短期 DoS 防御的角度来看,从磁盘访问 24 kB 并不比从磁盘访问 32 字节昂贵得多,因此无需担心代码大小。

添加 accessed_addresses / accessed_storage_keys 集合

添加已访问账户和存储槽的集合是为了避免不必要地对可以缓存(并且在所有高性能实现中已经缓存)的事物收费。此外,它消除了当前不理想的现状,即进行自我调用或调用预编译合约不必要地难以承受,并启用了合约破坏缓解措施,这些措施涉及预取一些存储键,从而允许未来的执行仍然花费预期的 gas 量。

SSTORE gas 成本变化

需要对 SSTORE 进行更改,以避免 DoS 攻击的可能性,这种攻击会“探测”一个随机选择的零存储槽,将其从 0 更改为 0,成本为 800 gas,但需要实际进行存储加载。SSTORE_RESET_GAS 的减少确保了 SSTORE 的总成本(现在需要支付 COLD_SLOAD_COST)保持不变。此外,请注意,执行 SLOAD 后跟 SSTORE 的应用程序(例如 $storage\_variable += x$实际上会变得更便宜

仅最小化地更改 SSTORE 记账

SSTORE gas 成本继续使用 Wei Tang 的原始/当前/新方法,而不是重新设计为使用脏映射,因为 Wei Tang 的方法正确地核算了更改存储的实际成本,这些成本只关心当前值与最终值,而不关心中间值。

在此提案下,普通应用程序的 gas 消耗将如何增加?

来自见证大小的粗略分析

我们可以查看 Alexey Akhunov 早期工作中的平均区块数据。总的来说,平均区块的见证大小约为 $\sim 1000 \text{ kB}$,其中 $\sim 750 \text{ kB}$ 是 Merkle 证明而不是代码。假设每个 Merkle 分支保守估计为 2000 字节,这意味着每个区块约有 $\sim 375$ 次访问(SLOAD 具有相似的 gas 增加与字节比率,因此无需单独分析)。

来自 Etherscan 的每日交易数每日区块数数据表明,每个区块约有 $\sim 160$ 笔交易(参考日期:7 月 1 日),这意味着这些访问中的很大一部分只是 tx.sendertx.to,它们不包括在 gas 成本增加中,尽管由于重复地址,可能少于 $\sim 320$。

因此,这暗示每个区块有 $\sim 50-375$ 次可计费访问,每次访问的 gas 成本增加 1900;$50 * 1900 = 95000$$375 * 1900 = 712500$,这意味着 gas limit 需要提高 $\sim 1-6\%$ 来补偿。然而,这种分析可能会因以下原因在两个方向上进一步复杂化:(i) 账户/存储键在多个交易中被访问,这将只在见证中出现一次,但在 gas 成本增加中出现两次;(ii) 账户/存储键在同一交易中被多次访问,这将导致 gas 成本降低

Goerli 分析

通过扫描 Goerli 交易可以找到更精确的分析,Martin Swende 在这里完成:https://github.com/holiman/gasreprice

结论是,平均 gas 成本增加了 $\sim 2.36\%$。导致 gas 成本降低的一个主要因素是,大量合约低效地多次读取相同的存储槽,这导致此 EIP 为少数交易带来了超过 10% 的 gas 成本节省

向后兼容性

这些 gas 成本增加可能会破坏依赖固定 gas 成本的合约;有关详细信息和为何我们预期总风险较低以及如果需要如何进一步降低风险的论证,请参阅安全考虑部分。

测试用例

一些测试用例可以在这里找到:https://gist.github.com/holiman/174548cad102096858583c6fbbb0649a

理想情况下,我们将测试以下内容:

  • SLOAD 相同的存储槽 ${1, 2, 3}$ 次
  • CALL 相同的地址 ${1, 2, 3}$ 次
  • 子调用中 $(SLOAD \text{ | } CALL)$,然后回滚,然后再次 $(SLOAD \text{ | } CALL)$ 相同的 $(storage \text{ slot | } address)$
  • 子调用,SLOAD,再次子调用,回滚内部子调用,SLOAD 相同的存储槽
  • SSTORE 相同的存储槽 ${1, 2, 3}$ 次,使用原始值和设置值的所有零/非零组合
  • SSTORE 后跟 SLOAD 相同的存储槽
  • $OP_1$ 后跟 $OP_2$ 到相同的地址,其中 $OP_1$$OP_2$ 是 $(CALL, EXT, SELFDESTRUCT)$ 的所有组合
  • 尝试 CALL 一个地址,但使用所有可能的失败模式(gas 不足,ETH 不足...),然后成功地再次 $(CALL \text{ | } EXT*)$ 该地址

实现

Geth 的一个早期 WIP 实现可以在这里找到:https://github.com/holiman/go-ethereum/tree/access_lists

安全考虑

与任何增加 gas 成本的 EIP 一样,在三种可能的情况下,它可能导致应用程序中断:

  1. 合约中子调用的固定 gas limit
  2. 依赖接近全部 gas limit 的合约调用的应用程序
  3. ETH 转账调用给被调用者的 2300 基本 limit

这些风险之前在早期 gas 成本增加 EIP-1884 的背景下进行了研究。请参阅 Martin Swende 之前的报告Hubert Ritzdorf 的分析,重点关注 (1) 和 (3)。(2) 受到的分析较少,尽管可以认为这不太可能,因为应用程序很少在交易中接近使用整个 gas limit,并且 gas limit 最近才从 1000 万增加到 1250 万。EIP-1884 在实践中确实导致少量合约中断,原因在于此。

有两种方法来看待这些风险。首先,我们可以注意到,截至今天,开发者已经有了多年的警告;关于存储访问操作码的 gas 成本增加已经讨论了很长时间,并且多次向主要 dapp 开发者发表了关于此类更改可能性的声明。EIP-1884 本身提供了一个重要的警示。因此,我们可以认为这次的风险将显著低于 EIP-1884。

合约中断缓解

看待风险的第二种方式是探索缓解措施。首先,accessed_addressesaccessed_storage_keys 映射的存在(此 EIP 中存在,EIP-1884 中不存在)已经使某些情况可恢复:在任何情况下,如果合约 A 需要向地址 B 发送资金,而该地址 B 接受来自任何来源的资金但留下一个依赖于存储的日志,可以通过首先向 B 发送一个单独的调用将其拉入缓存,然后调用 A 来恢复,因为 A 触发的 B 的执行将只收取每个 SLOAD 100 gas。这个事实并不能解决所有情况,但确实显著降低了风险。

但是有办法进一步扩展这种模式的可用性。一种可能性是添加一个 POKE 预编译合约,它将地址和存储键作为输入,并允许试图通过预先“戳”所有它们将访问的存储槽来“拯救”卡住合约的交易。即使地址只接受来自合约的交易,并且在许多其他具有当前 gas limit 的上下文中也有效,这种方法也有效。唯一不起作用的情况是,交易调用必须从 EOA 直接进入某个特定合约,然后该合约再子调用另一个合约。

另一个选项是 EIP-2930,它将与 POKE 具有类似的效果,但更通用:它也适用于 EOA -> 合约 -> 合约 的情况,并且通常应该适用于所有已知因 gas 成本增加而中断的情况。这个选项更复杂,尽管它可以说是访问列表用于其他用例(regenesis,账户抽象,SSA 都需要访问列表)的一个垫脚石。

版权

通过 CC0 放弃版权及相关权利。

  • 原文链接: github.com/nerolation/EI...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Nerolation
Nerolation
江湖只有他的大名,没有他的介绍。