Alert Source Discuss
⚠️ Review Standards Track: Core

EIP-7069: 改进的 CALL 指令

引入具有简化语义的 EXTCALL、EXTDELEGATECALL 和 EXTSTATICCALL

Authors Alex Beregszaszi (@axic), Paweł Bylica (@chfast), Danno Ferrin (@shemnon), Andrei Maiboroda (@gumb0), Charles Cooper (@charles-cooper)
Created 2023-05-05
Requires EIP-150, EIP-211, EIP-214, EIP-2929, EIP-3540

摘要

引入三个新的调用指令 EXTCALLEXTDELEGATECALLEXTSTATICCALL,它们具有简化的语义。 引入另一个指令 RETURNDATALOAD,用于将返回数据中的一个字加载到堆栈中。 修改在 EOF 格式代码中执行的 RETURNDATACOPY 指令的行为(如 EIP-3540 所定义)。 现有的 *CALL 指令会被 EOF 验证拒绝。

新指令不允许指定 gas 限制,而是依赖于 “63/64 规则” (EIP-150) 来限制 gas。 一个重要的改进是围绕 “津贴” 的规则被简化,调用者不需要执行特殊计算来确定是否发送了 value。

此外,已删除指定输出缓冲区地址的过时功能,而改用 RETURNDATACOPY。对于以前将 *CALL 输出到缓冲区然后从缓冲区 MLOAD 的情况,提供了 RETURNDATALOAD

最后,不是返回一个布尔值来表示执行状态,而是返回一个可扩展的状态代码列表:0 表示成功,1 表示回滚,2 表示失败。

动机

Gas 的可观察性已经存在很长时间的问题。Gas 系统在适应以太坊的使用方式以及底层硬件的变化方面一直是(并且可能必须)灵活的。

不幸的是,在许多情况下,必须做出妥协或变通方法,以避免对调用指令产生负面影响,这主要是由于其复杂的语义和期望。

此更改从新指令中删除 gas 可观察性,并为不受重新定价影响的新类型的合约打开了大门。此外,传统调用指令在 EOF 合约中被拒绝,确保它们在很大程度上不受 gas 费用的变化的影响。由于这些操作是删除 gas 可观察性所必需的,因此它们是 EOF 所必需的,以代替现有的传统指令。

重要的是要注意,从 Solidity 0.4.21 开始,编译器已经将所有剩余的 gas 传递给调用(使用 call(gas(), ...)),除非开发人员在语言中使用显式覆盖({gas: ...})。这表明大多数合约不依赖于控制 gas。

除了上述之外,此更改还引入了一个方便的功能,即返回更详细的状态代码:success (0)revert (1)failure (2)。这从布尔选项转变为代码,将来可以扩展这些代码。

最后,RETURNDATA* 指令 (EIP-211) 的引入已使调用的输出参数过时,在许多情况下使其未使用。使用输出缓冲区过去曾引起 “错误”:在 ERC-20 的情况下,冲突的实现引起了很多麻烦,其中一些会返回某些东西,而另一些则不会。通过依赖 RETURNDATA* 指令,这一点得到了明确的阐明。该提案还增加了 “缺失的” RETURNDATALOAD 指令,以完善返回数据缓冲区访问指令。

规范

名称 注释
WARM_STORAGE_READ_COST 100 来自 EIP-2929
COLD_ACCOUNT_ACCESS 2600 来自 EIP-2929
CALL_VALUE_COST 9000  
ACCOUNT_CREATION_COST 25000  
MIN_RETAINED_GAS 5000  
MIN_CALLEE_GAS 2300  

我们引入四个新指令:

  • EXTCALL (0xf8),带有参数 (target_address, input_offset, input_size, value)
  • EXTDELEGATECALL (0xf9),带有参数 (target_address, input_offset, input_size)
  • EXTSTATICCALL (0xfb),带有参数 (target_address, input_offset, input_size)
  • RETURNDATALOAD (0xf7),带有参数 offset

这四个新指令在传统代码中未定义,仅在 EOF 代码中可用。

EXT*CALL 的执行语义:

  1. 收取 WARM_STORAGE_READ_COST gas。
  2. 从堆栈中弹出所需的参数,如果堆栈下溢,则以异常失败停止。
    • 注意:在 EOF 中实现时,堆栈下溢检查是在堆栈验证期间完成的,并且省略了运行时检查。
  3. 如果 value 非零(仅 EXTCALL):
    • 如果当前帧处于 static-mode,则以异常失败停止。
    • 收取 CALL_VALUE_COST gas。
  4. 如果 target_address 的任何高 12 位设置为非零值(即,它不包含 20 字节地址),则以异常失败停止。
  5. 使用 [input_offset, input_size] 执行(并收取费用)内存扩展。
  6. 如果 target_address 不在 warm_account_list 中,则收取 COLD_ACCOUNT_ACCESS - WARM_STORAGE_READ_COST gas。
  7. 如果 target_address 不在状态中,并且调用配置将导致帐户创建,则收取 ACCOUNT_CREATION_COST gas。
    • 此 EIP 中唯一的情况是 value 非零(仅 EXTCALL)。
  8. 将调用者剩余的 gas 减少 max(floor(gas/64), MIN_RETAINED_GAS) 来计算可用于被调用者的 gas。
  9. 清除返回数据缓冲区。
  10. 如果以下任何一项为真,则在堆栈上返回状态代码 1 失败(仅消耗到此为止收取的 gas):
    • 此时可用于被调用者的 Gas 小于 MIN_CALLEE_GAS
    • 当前帐户的余额小于 value(仅 EXTCALL)。
    • 当前调用堆栈深度等于 1024
    • (仅 EXTDELEGATECALL) 状态中的 target_address 帐户没有 EOF 代码要执行(特别是,应解析 EIP-7702 委托,就像使用了 EXTCALL 一样)
  11. 使用可用 gas 和配置执行调用。
  12. 将状态代码推送到堆栈上:
    • 如果调用成功,则为 0
    • 如果调用已回滚,则为 1(也可以在步骤 10 中描述的轻微故障情况下更早地推送)。
    • 如果调用失败,则为 2(在发生一般 OOG 故障或所有传递给被调用者的 gas 由于错误而被消耗的情况下)。
  13. 被调用者未使用的 Gas 将返回给调用者。

RETURNDATALOAD 的执行语义:

  1. 收取 3 gas
  2. 从堆栈中弹出 1 个项目,称为 offset
  3. 将 1 个项目推送到堆栈上,即从 offset 开始从返回数据缓冲区读取的 32 字节字。
  4. 如果 offset + 32 > len(returndata buffer),则结果用零填充。

EOF 格式代码中 RETURNDATACOPY 的执行语义 (EIP-3540) 修改如下:

  1. 假设从堆栈中弹出的 3 个参数为 destOffsetoffsetsize(没有变化)
  2. 执行内存扩展到 destOffset + size 并扣除内存扩展成本。 (没有变化)
  3. 如果 offset + size > len(returndata buffer) 不要以异常失败停止,而是将复制的字节后的 offset + size - len(returndata buffer) 内存字节设置为零。
  4. 内存复制收取的 Gas 仍然是 3 * num_words(size),无论实际复制或设置为零的字节数是多少。

未采用 EOF 格式代码(即在传统代码中)的 RETURNDATACOPY 的执行没有更改。

原理

删除 gas 可选择性

与原始 CALL 系列指令的一个主要变化是,调用者无法控制作为调用一部分传递的 gas 量。在需要此类功能的少数情况下,最好通过直接协议集成来提供服务。

删除 gas 可选择性还引入了一个有价值的属性,即 gas 计划的未来修订将从中受益:您始终可以通过作为事务的一部分发送更多 gas 来克服 Out of Gas (OOG) 错误(受区块 gas 限制的约束)。以前,在提高存储成本时 (EIP-1884),一些仅将有限数量的 gas 发送到其调用的合约被新的成本核算破坏了。

因此,某些合约对其下一次调用发送了一个 gas 上限,从而永久限制了它们可以花费的 gas 量。再多的额外 gas 也无法解决此问题,因为调用会限制发送的量。津贴下限的概念保留在此规范中。可以独立于智能合约更改此下限,并且仍然保留 OOG 停止可以通过作为事务的一部分发送更多 gas 来修复的功能。

津贴和 63/64 规则

津贴的目的是在调用 “合约钱包” 时有足够的 gas 来发出日志(即执行非状态更改操作)。仅当使用 CALL 指令且 value 非零时才添加津贴。

63/64 规则有多个目的:

a. 限制调用深度, b. 确保调用者在被调用者返回后留有 gas 来进行状态更改。

此外,还有一个调用深度计数器,如果深度超过 1024,则调用将失败。

在引入 63/64 规则之前,需要在调用者端半准确地计算可用 gas。Solidity 有一个复杂的规则集,它尝试估计在调用者端执行调用本身需要多少成本,以便设置一个合理的 gas 值。

我们更改了规则集:

63/64 规则仍然适用,但是 - 在执行被调用者之前,至少保留 MIN_RETAINED_GAS gas, - 被调用者至少可以使用 MIN_CALLEE_GAS gas。

MIN_CALLEE_GAS 规则是津贴的替代品:它简化了关于 gas 成本的推理,并且统一应用于所有引入的 EXT*CALL 指令。 下表可视化了差异(请注意 CALL调用者所需 gas调用者成本 之间的差异)。

  调用者所需 gas 调用者成本(燃烧 gas) 调用者最小保留 gas 被调用者最小 gas
CALL V=0 100 100 0 0
CALL V≠0 100+9000 100+6700 0 2300
DELEGATECALL 100 100 0 0
STATICCALL 100 100 0 0
EXTCALL V=0 100 100 5000 2300
EXTCALL V≠0 100+9000 100+9000 5000 2300
EXTDELEGATECALL 100 100 5000 2300
EXTSTATICCALL 100 100 5000 2300
  • 调用者所需 gas:调用者执行调用指令所需的最小 gas 量,较低的值会导致调用者的 OOG,
  • 调用者成本(燃烧 gas):从调用者处扣除的 gas 量以执行指令,此 gas 量不适用于被调用者,
  • 调用者最小保留 gas:调用者保证在调用后拥有的最小 gas 量,如果无法保证,则调用失败,甚至无法到达被调用者,
  • 被调用者最小 gas:被调用者执行的最小 gas 限制。

最初考虑删除调用堆栈深度检查,但这与原始 *CALL 指令以及 CREATE* 指令不兼容,这些指令可以与调用堆栈中的新 EXT*CALL 指令交织在一起。因此,保留调用堆栈深度检查不涉及影响传统代码的任何更改。

此外,我们发现简单(与复杂的 63/64 规则相反)的硬性上限令人放心,即调用堆栈深度受到限制,以防 gas 规则可以被绕过。最后,达到 1024 深度的 gas 量是巨大的,但并非荒谬的巨大,我们希望避免通过此检查对当前 gas 限制的依赖性来约束自己。

输出缓冲区

删除了指定输出缓冲区地址的功能,因为这增加了复杂性,并且在许多情况下,实现者更喜欢使用 RETURNDATACOPY。即使他们依赖输出缓冲区(如 Vyper 的情况),他们仍然会使用 RETURNDATASIZE 检查长度。在 Solidity 中,一个例外是已知预期返回大小的情况(即非动态返回值),在这种情况下,Solidity 仍然使用输出缓冲区。对于这些情况,引入了 RETURNDATALOAD,它简化了将返回数据复制到(已知)输出缓冲区并从那里使用 MLOAD 的工作流程;相反,可以直接使用 RETURNDATALOAD

状态代码

当前调用指令返回一个布尔值以指示成功:0 表示失败,1 表示成功。Solidity 编译器假定此值是一个布尔值,因此将该值用作分支条件来指示状态(if iszero(status) { /* failure */ })。这阻止我们在不破坏现有合约的情况下引入新的状态代码。在设计 EIP-211 时,曾讨论过为回滚返回特定代码的想法,但最终由于上述原因而放弃。

我们将值从布尔值更改为状态代码,其中 0 表示成功,因此如果需要,将来可以引入更多非成功代码。

状态代码 1 用于来自被调用者帧的回滚和遇到的轻微故障(请参阅执行语义的步骤 10)。将它们组合在一起的原因是保持语义与原始 CALL 相似 - 这两种情况都保留了未使用的 gas,并且调用者仍然无法区分。

状态代码 2 表示被调用者执行中发生异常失败,这意味着发生了一个错误,该错误消耗了被调用者帧中所有剩余的 gas。

参数顺序

参数的顺序已更改,以将 value 字段移动到最后。这使得指令具有相同的编码,但最后一个参数除外,并稍微简化了 EVM 和编译器实现。

操作码编码

我们讨论了一个带有立即配置字节(标志)的版本,而不是引入三个新的 EXT*CALL 操作码。这有两个主要缺点:

  1. 某些标志组合可能没有用/无效,这会增加测试/实现复杂性。
  2. 该指令可能采用可变数量的堆栈项(即 EXTCALLvalue)将是一个全新的概念,其他任何指令都没有。

将这些作为新的操作码而不是在 EOF 内部修改现有的 CALL 系列也很有用。如果在 EOF 合约中需要恢复 gas 可观察性,这将创建一个 “转义舱口”。这是通过将 GAS 和原始 CALL 系列操作码添加到有效的 EOF 操作码列表中来完成的。

CALLCODE

由于 CALLCODE 已弃用,因此我们此处不引入对应项。

target_address 不是 20 字节的以太坊地址时停止

当现有 CALL 系列操作遇到不适合 20 字节的地址时,当前行为是屏蔽该地址,使其适合 20 字节,忽略所有高字节。对于 EXT*CALL 操作,选择停止而不是将合约视为空,原因有两个。首先,它处理将 value 发送到不存在的地址的情况,而无需创建特殊情况。其次,它使 warm_access_list 无需跟踪任何非 20 字节以太坊地址的内容。

智能合约开发人员不应依赖于在传入此类地址时操作是否会回滚。当采用适当的地址空间扩展提案时,预计 EXT*CALL 系列操作将采用这些更改。

新指令在传统中未定义(此 EIP 仅是 EOF 的一部分)

最初考虑了一种替代方案,即首先在传统 EVM 中单独引入这些新指令,以限制 EOF 更改的范围,但决定将其作为 EOF 升级的一部分包含在内,并在传统 EVM 中保持未定义。

RETURNDATALOADRETURNDATACOPY 填充行为

此 EIP 最初建议保留传统 RETURNDATACOPY 的 halt-on-OOB 行为。这使得编译器优化更加困难,因为不必要的 RETURNDATA* 指令无法在不更改代码语义的情况下进行优化。

可能只有 RETURNDATALOAD 具有填充行为,但这会使其与密切相关的 RETURNDATACOPY 指令不一致,从而造成混乱。

还有另一种方法是引入带有填充行为的 RETURNDATACOPY2,仅在 EOF 中可用,同时禁止在 EOF 中使用 RETURNDATACOPY。为了避免增加操作码,并且从编译器实现的角度来看也是次优的,因此拒绝了此方法。

EOF1 合约只能 EXTDELEGATECALL EOF1 合约

传统合约可以通过三种不同的方式进行 selfdestruct(直接通过 SELFDESTRUCT、间接通过 CALLCODE 和间接通过 DELEGATECALL)。EIP-3670 禁用了前两种可能性,但是第三种可能性仍然存在。允许 EOF1 合约仅 EXTDELEGATECALL 其他 EOF1 合约允许以下强烈的声明:EOF1 合约永远不会被销毁。基于 SELFDESTRUCT 的攻击对于 EOF1 合约完全消失。这些攻击包括销毁的库合约(例如 Parity Multisig)。

向后兼容性

没有更改任何现有指令,因此我们认为不会出现任何向后兼容性问题。

安全注意事项

预计攻击面不会增长。所有这些操作都可以通过具有固定 gas(所有可用)和输出范围(零内存中的零长度)的现有操作来建模。

在 EOF 中实现时(GAS 操作码和原始 CALL 操作被删除),现有的 gas 不足攻击的难度会略有增加,但并非完全阻止。事务仍然可以传入任意 gas 值,并且巧妙的合约构造仍然会导致将特定的 gas 值传递给特定的调用。预计相同的表面将在 EOF 中保留,但利用的难易程度将降低。

传统的 *CALL 指令允许传递可用于被调用者的 gas。此功能用于防止重入攻击,但在潜在的未来 gas 重新定价提案中,此模式不再提供此保证。应通过使用 EIP-1153 中引入的 TSTORE/TLOAD 指令或其他不依赖于当前 gas 计划的解决方案来解决防止重入的问题。

版权

CC0 下放弃版权及相关权利。

Citation

Please cite this document as:

Alex Beregszaszi (@axic), Paweł Bylica (@chfast), Danno Ferrin (@shemnon), Andrei Maiboroda (@gumb0), Charles Cooper (@charles-cooper), "EIP-7069: 改进的 CALL 指令 [DRAFT]," Ethereum Improvement Proposals, no. 7069, May 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7069.