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 |
Table of Contents
摘要
引入三个新的调用指令 EXTCALL
、EXTDELEGATECALL
和 EXTSTATICCALL
,它们具有简化的语义。 引入另一个指令 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
的执行语义:
- 收取
WARM_STORAGE_READ_COST
gas。 - 从堆栈中弹出所需的参数,如果堆栈下溢,则以异常失败停止。
- 注意:在 EOF 中实现时,堆栈下溢检查是在堆栈验证期间完成的,并且省略了运行时检查。
- 如果
value
非零(仅EXTCALL
):- 如果当前帧处于
static-mode
,则以异常失败停止。 - 收取
CALL_VALUE_COST
gas。
- 如果当前帧处于
- 如果
target_address
的任何高 12 位设置为非零值(即,它不包含 20 字节地址),则以异常失败停止。 - 使用
[input_offset, input_size]
执行(并收取费用)内存扩展。 - 如果
target_address
不在warm_account_list
中,则收取COLD_ACCOUNT_ACCESS - WARM_STORAGE_READ_COST
gas。 - 如果
target_address
不在状态中,并且调用配置将导致帐户创建,则收取ACCOUNT_CREATION_COST
gas。- 此 EIP 中唯一的情况是
value
非零(仅EXTCALL
)。
- 此 EIP 中唯一的情况是
- 将调用者剩余的 gas 减少
max(floor(gas/64), MIN_RETAINED_GAS)
来计算可用于被调用者的 gas。 - 清除返回数据缓冲区。
- 如果以下任何一项为真,则在堆栈上返回状态代码
1
失败(仅消耗到此为止收取的 gas):- 此时可用于被调用者的 Gas 小于
MIN_CALLEE_GAS
。 - 当前帐户的余额小于
value
(仅EXTCALL
)。 - 当前调用堆栈深度等于
1024
。 - (仅
EXTDELEGATECALL
) 状态中的target_address
帐户没有 EOF 代码要执行(特别是,应解析 EIP-7702 委托,就像使用了EXTCALL
一样)
- 此时可用于被调用者的 Gas 小于
- 使用可用 gas 和配置执行调用。
- 将状态代码推送到堆栈上:
- 如果调用成功,则为
0
。 - 如果调用已回滚,则为
1
(也可以在步骤 10 中描述的轻微故障情况下更早地推送)。 - 如果调用失败,则为
2
(在发生一般 OOG 故障或所有传递给被调用者的 gas 由于错误而被消耗的情况下)。
- 如果调用成功,则为
- 被调用者未使用的 Gas 将返回给调用者。
RETURNDATALOAD
的执行语义:
- 收取
3
gas - 从堆栈中弹出 1 个项目,称为
offset
- 将 1 个项目推送到堆栈上,即从
offset
开始从返回数据缓冲区读取的 32 字节字。 - 如果
offset + 32 > len(returndata buffer)
,则结果用零填充。
EOF 格式代码中 RETURNDATACOPY
的执行语义 (EIP-3540) 修改如下:
- 假设从堆栈中弹出的 3 个参数为
destOffset
、offset
和size
。 (没有变化) - 执行内存扩展到
destOffset + size
并扣除内存扩展成本。 (没有变化) - 如果
offset + size > len(returndata buffer)
不要以异常失败停止,而是将复制的字节后的offset + size - len(returndata buffer)
内存字节设置为零。 - 内存复制收取的 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
操作码。这有两个主要缺点:
- 某些标志组合可能没有用/无效,这会增加测试/实现复杂性。
- 该指令可能采用可变数量的堆栈项(即
EXTCALL
的value
)将是一个全新的概念,其他任何指令都没有。
将这些作为新的操作码而不是在 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 中保持未定义。
RETURNDATALOAD
和 RETURNDATACOPY
填充行为
此 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.