ERC-4337 Gas 预估的工作原理
在构建 Alchemy 的 ERC-4337 Bundler(称为 "Rundler")的过程中,最难攻克的部分一直是 user operation 的 gas 估算。本文将介绍我们在为用户提供准确 gas 估算时遇到的障碍,以及我们目前采用的解决方案。
这是一篇面向 ERC-4337 开发者的技术概述。如果你刚接触 ERC-4337 风格的账户抽象,建议先阅读我们的 Account Abstraction 系列入门文章。
为什么 gas 估算对 ERC-4337 user operation 很重要?
提供准确的 user operation gas 估算对 ERC-4337 的用户体验至关重要。如果 gas 估算值过低,user operation 可能会在模拟阶段回滚,更糟的是,可能在执行阶段链上回滚,导致用户为一个已回滚的操作支付 gas 费用。如果 gas 估算值过高,用户可能会因为成本问题而放弃发送操作,或者根本无法发送。
虽然准确性很重要,但 gas 估算并不需要做到 100% 精确,只要误差始终是高估(但不要高估太多)即可。在 ERC-4337 中,gas 字段表示为限额,用户未在链上消耗的 gas 会被退还。因此,gas 估算并不会影响操作的实际成本。
💡 *preVerificationGas 除外,后文会详细说明。
ERC-4337 gas 字段定义
user operation 中的 gas 字段及其在 ERC-4337 规范 中的定义如下:
preVerificationGas:为补偿 bundler 在预验证执行和 calldata 上付出的成本而支付的 gas 数量。verificationGasLimit:为验证阶段分配的 gas 数量。callGasLimit:为主执行调用分配的 gas 数量。maxFeePerGas:每单位 gas 的最高手续费(类似于 EIP-1559 中的max\_fee\_per\_gas)。maxPriorityFeePerGas:每单位 gas 的最高优先费(类似于 EIP-1559 中的max\_priority\_fee\_per\_gas)。
ERC-4337 gas 计量
ERC-4337 bundler 会预先支付将一笔 bundle 交易发送给 entry point 合约的费用。entry point 会计量每个 user operation 所消耗的 gas,将其乘以计算出的费用,并在 user operation 完成后按此金额补偿 bundler。
实际的计算方式如下:*
💡 *为简化起见,这里仅针对支持 EIP-1559 的网络。
preVerificationGas 会被原样计入,而验证和调用阶段的 gas 会被"计量"。也就是说,其 gas 使用量由 entry point 在链上测量,并按实际使用量收费,上限为各自的限额。如果在验证或调用阶段达到了限额,该操作将回滚。
entry point 无法处理 gas 退款,因为它是在一笔交易的过程中计量 gas 的,而 gas 退款是在交易完成后才发放的,因此会直接返还给 Bundler。这对用户来说是一项额外的成本来源,同时也是 bundler 潜在的收入来源。
User operation 流程
发送 user operation 的典型流程包括以下步骤:
- 构造一个部分填充的 user operation,填入
sender、nonce、initCode和callData。 a. 同时,为signature和paymasterAndData填入"占位"值。 - 通过 bundler RPC 的
eth\_estimateUserOperationGas方法为这个部分 user operation 估算 gas。 a. 根据返回值填充preVerificationGas、verificationGasLimit、callGasLimit。 - 估算该操作所需的 gas 费用,并填充
maxFeePerGas和maxPriorityFeePerGas。 a. 这一步不依赖于步骤 1 或 2,可以随时运行,或与其并行执行。 - (可选)将 user operation 发送给赞助方 paymaster 进行签名
a. 根据返回值填充
paymasterAndData。 - 对上述 user operation 进行签名,填充
signature,然后通过eth\_sendUserOperation发送给 bundler。
本文将聚焦于步骤 2 以及 bundler RPC 方法 eth\_estimateUserOperationGas。
User operation gas 估算
eth\_estimateUserOperationGas 是 bundler 根据 ERC-4337 规范 必须支持的一个 RPC 方法。
其定义为:
为一个 UserOperation 估算 gas 值。给定一个可选地不包含 gas 限额和 gas 价格的 UserOperation,返回所需的 gas 限额。signature 字段会被钱包忽略,因此该操作无需经过用户批准。不过,仍可能需要填入一个"半有效"的签名(例如,长度正确的签名)。
*参数**:与 eth_sendUserOperation 相同。gas 限额(以及价格)参数是可选的,但如果指定则会被使用。maxFeePerGas 和 maxPriorityFeePerGas 默认为零,因此账户和 paymaster 都无需支付费用。*
返回值:
- *preVerificationGas** 该 UserOperation 的 gas 开销*
- *verificationGasLimit** 该 UserOperation 验证阶段实际使用的 gas*
- *callGasLimit** 账户内部执行所使用的值*
PreVerificationGas 计算
PreVerificationGas 用于捕获 entry point 未计量的任何 gas 使用量,以此补偿 bundler。
在简单情况下,可以将其拆分为 3 项独立计算:
- 该操作在 bundle 交易固有 gas 中所占的份额。 a. 注意,bundler 必须事先假设一个 bundle 大小来确定这一份额。
- 该操作在 calldata gas 成本中所占的份额。 a. 这直接取决于操作的大小和字节构成。
- 该操作在 entry point 产生的、未被计量的任何执行 gas 开销中所占的份额。 a. 这是通过链下对 entry point 合约进行 gas 使用量分析,并按每个 user operation 分摊得出的。
请注意,preVerificationGas 并非限额。也就是说,该字段设置的值会全额支付给 bundler。使用此字段时要格外小心,因为错误的值可能意味着支付给 bundler 的费用超出了实际所需!
更多细节可参阅 StackUp 团队撰写的这篇关于 preVerificationGas 的深度分析。
bundler 通常会在"预检查"阶段(即在完整的基于 trace 的模拟之前运行的检查)进行 preVerificationGas 的计算。
如果某个操作的 preVerificationGas 低于 bundler 计算出的值,bundler 会将其拒绝。这可能成为不同 bundler 实现之间产生不兼容的一个原因。
如果某个 bundler 实现 L 估算的 preVerificationGas 值较低,而另一个实现 H 估算(并要求)的值较高,那么 H 会拒绝那些使用 L 进行估算的操作。
这种不兼容性将如何影响 P2P mempool 尚待观察,但用户很可能会倾向于稍微多估算一些 preVerificationGas,以最大化其操作被打包的概率。
Alchemy 的 ERC-4337 bundler 如何估算 gas 限额
为 verificationGasLimit 和 callGasLimit 这两个 gas 限额字段提供准确的估算非常重要。这个目标可以表述为:提供一个函数,估算这些阶段所消耗的 gas,同时确保只会高估,但不能高估太多,以免打消用户发送操作的意愿。
让我们来看看我们在 Rundler 中尝试实现这一函数所做的各种尝试。
尝试 1:eth\_estimateGas
标准 Ethereum JSON-RPC 提供了一个很好的方法来估算交易的 gas,那就用 eth\_estimateGas 吧!
方法如下:
verificationGasLimit:调用 entry point 上的simulateValidation函数,并传入eth\_estimateGascallGasLimit:调用 entry point 上的innerHandleOp函数,并传入eth\_estimateGas
看起来效果不错。虽然 simulateValidation 和 innerHandleOp 都无法精确捕捉各自阶段中被计量的部分,但它们严格来说是超集,会略微高估。
需要注意的是,这些调用是完全独立的,但在链上是在同一笔交易中执行的。在这种方法中,simulateValidation 的执行结果并不会持久化,因而不会影响 innerHandleOp。
如果 innerHandleOp 依赖于 simulateValidation 期间完成的某些操作怎么办?
这种情况适用于账户的初始部署,也可能适用于某些高级验证方案。工厂方法会在验证阶段被调用以部署账户合约。而执行阶段需要该合约已经被部署,否则 innerHandleOp 会回滚,导致任何估算尝试失败。
我们需要一种方法,能够同时估算这两个 gas 限额字段,并确保执行阶段的估算是在与验证阶段相同的调用中运行的。
进入下一个尝试……
尝试 2:模拟并测量
ERC-4337 v0.6 的实现提供了一个 simulateHandleOp 方法,将验证和执行两个阶段合并到一次函数调用中。它的返回值包含 preOpGas,即验证阶段与 preVerificationGas 所消耗的 gas 之和。它还包含 user operation 支付的总金额 paid。
要确定 callGasLimit,可以将 paid 除以 gas 费用,转换为所消耗的 gas。bundler 可以通过将模拟固定在一个(近期的)具有已知基础费用的区块高度,并将优先费设为 0,来确定 gas 费用。经过一些简单的转换,所有 gas 字段都可以计算出来。这种方法同样可能高估所消耗的 gas,因为一部分未被计量的 entry point 逻辑也被计入了 gas 使用量。
另一种合理的方法是:测量调用阶段所消耗的 gas,方式是测量整个 simulateHandleOp 调用所消耗的 gas,然后减去验证阶段所消耗的 gas。
我们可以部署一个辅助合约来计量 simulateHandleOp 所消耗的 gas,并将其附加到返回值中。这种方法与前一种方法存在同样的高估问题。
对于那些在意高估问题的人,我们可以进一步优化这个方法。
我们能否通过追踪 entry point 的 simulateHandleOp 方法来计算 gas?
bundler 必须具备运行 trace call 的能力,以便执行保护自身免受链上失效影响所需的模拟检查。我们能否利用这一功能来估算 gas?
方法如下:
- 追踪 entry point 的
simulateHandleOp方法,该方法巧妙地使用了一种"数字标记"方案,使得 tracer 能够准确知道当前处于哪个执行阶段。 - 在 tracer 中,记录每个阶段开始时以及最后阶段结束时剩余的 gas 数量。
- 根据这些数值,计算每个阶段所消耗的 gas 数量。
很好!我们通过略微更昂贵的 trace 精确计算出了所消耗的 gas 数量。
且慢,使用上述任何一种方法,都可能导致某些合约调用因 gas 不足而失败。
为什么?
要回答这个问题,我们可以从 Reth 如何估算 gas 中获得启发。注意到那个二分查找了吗?那是在做什么?为什么 Reth 不直接使用类似上文的 tracing 方法呢?毕竟它能访问到相同的所有信息。
事实证明,在一次 EVM 函数调用中:消耗的 gas ≠ 所需的 gas。
关于为什么会这样,可以参阅 Sergio Lerner 撰写的文章"The Dark Side of Ethereum 1/64th CALL Gas Reduction"。其中最重要的原因被称为"63/64 规则"(或者,根据交流对象的不同,也被称为"1/64 规则")。
该规则规定,EVM 在每次函数调用时只会转发剩余 gas 的 63/64。其结果是,你在事前需要准备比最终实际消耗量略多一些的 gas。这一现象会在每次调用时发生,因此调用栈较深的验证/执行阶段可能需要预留大量的 gas,以应对这部分被"预留"的 gas。
我们希望方案能够适用于所有可能的 user operation,因此任何使用已消耗 gas 测量值来进行 gas 限额估算的尝试都是不可取的。在下一次尝试中,让我们从 EVM 客户端那里汲取灵感。
💡 也许可以对一个 tracing 函数进行详细分析,并结合 63/64 规则(以及其他 EVM 细节),从消耗的 gas 反推出 gas 限额。我们尚未探索这个方向。
尝试 3:二分查找
我们没必要重复造轮子。为了应对上述复杂情况,节点客户端在 gas 估算时使用二分查找,寻找能够使交易成功的最低 gas 值。我们可以对 user operation 采用类似的做法!
具体方法如下:
首先注意,由于二分查找需要对同一个函数进行多次调用,我们希望确保该函数具有较高的性能。这排除了在此处使用 tracing 的可行性,我们将需要依赖 eth\_call。
要估算 verificationGasLimit:
- 在
MAX\_VERIFICATION\_GAS(一个 bundler 设置,定义了验证阶段可使用的最大 gas)处运行一次simulateHandleOp尝试,以确认成功从一开始就是可能的。 a. 在此次运行中,将callGasLimit设为 0 以节省计算量。 - 在
simulateHandleOp上运行二分查找,以逼近不会耗尽 gas 的最低verificationGasLimit值。 a. 如果验证阶段 gas 耗尽,调用会回滚。由于我们已经确认了在更高 gas 限额下调用可以成功,任何回滚都可以使二分查找的下边界上移。
这里有一个不错的优化点:我们不需要 100% 精确的估算。bundler 可以自行选择估算的精确程度,并在结果处于该范围内时终止二分查找,同时始终偏向高估。这样可以节省多轮调用。例如,Rundler 将此误差范围设定为 1K gas,从而节省了 10 次迭代。
第二个可以节省若干迭代次数的优化是为查找确定一个更好的起始点。将步骤(1)中消耗的 gas 乘以一个系数(例如 Reth 使用 3 倍),并以此作为二分查找算法的初始猜测值。
要估算 callGasLimit:
- 将
verificationGasLimit设为MAX\_VERIFICATION\_GAS - 尝试运行
MAX\_EXECUTION\_GAS(这不属于规范的一部分,但 bundler 需要它来保护自身免受 DOS 攻击),并检查执行是否有可能成功。
这正是此估算方法的问题所在。回滚会被 entry point 捕获并以日志形式发出,而 eth\_call 无法提供检查所发出日志的方式。此外,它在每次尝试中都会完整地运行验证阶段,从而浪费计算量。
我们能否提供一种方式来运行这个 callGasLimit 二分查找?
💡 如果使用 trace,是可以检测到这次回滚的,但出于成本考虑我们希望避免这样做。如果某个 bundler 完全掌控了节点客户端,并能够使用原生 tracer,那么这个决定或许值得重新考虑。
解决方案:在 Solidity 中进行二分查找
基于 verificationGasLimit 估算的流程,该方法的核心思路是在 Solidity 中运行 callGasLimit 的二分查找,并编写能够捕获执行部分是否失败的逻辑。
让我们先定义约束条件:
- 二分查找必须在验证阶段运行之后进行,以确保任何账户合约都已部署完成 a. 一个理想但非必需的约束是:只运行一次验证(或限制运行次数),以减少不必要的计算浪费。
- execute 调用必须从 entry point 发起。许多账户实现只允许在
msg.sender等于硬编码的 entry point 地址时调用其 execute 函数。 - 二分查找必须返回其最终结果。
- 所有 entry point 函数的行为都必须与正常情况完全一致。
我们想出的解决方案是使用一个代理合约,通过 DELEGATECALL 转发到 entry point 合约,同时添加额外的逻辑。在估算过程中,我们使用 eth\_call 覆盖,将原始的 entry point 合约移动到一个随机地址,并用代理合约替换它。这满足了约束(2)和(4)。
如上所述,entry point 隐藏了执行阶段的结果。我们改为跳过标准的执行调用(将 callGasLimit 设为 0),并利用传给 simulateHandleOp 的 target/targetCallData 参数。这会在验证之后运行,并可以返回信息,从而满足约束(1)和(3)。请参阅下方的 entry point 代码(已添加注释):
对于我们自定义的目标调用,我们在代理合约上调用一个名为 estimateCallGas 的方法,其 call data 编码了:
- 账户及其调用数据(来自原始 user operation)
- 二分查找的参数
由于代理合约位于原始 entry point 的地址上,estimateCallGas 能够调用账户的 execute 函数,判断调用是成功还是因 gas 不足而失败,并运行二分查找算法直至成功。
大功告成!现在可以在 eth\_call 中进行二分查找,从而为 callGasLimit 获得准确的估算值!
在实践中,为了规避节点单次调用的最大 gas 限额,Rundler 将这一单一的 eth\_call 拆分为多次调用,每次调用都有一个最大的全局 gas 限额。每次调用会一直运行二分查找的迭代,直到下一次迭代会导致该调用耗尽 gas 为止。
💡 这种方法会遗漏 entry point 在 innerHandleOp 期间所产生的少量开销。这部分开销是固定的,会被加到估算结果中。
entry point 可能的改动
我们一直在思考对 entry point 可能进行哪些改动,从而使上述 gas 估算流程更易于实现,并为用户提供更高的性能。这不是一个容易解决的问题,因此如果其他 bundler 或 ERC-4337 社区成员有任何想法,我们非常乐意听取!
我们希望确保在 gas 估算过程中,验证阶段先于执行阶段运行。执行阶段可能依赖于验证阶段所设置的状态(例如,一次 USDC paymaster 转账,以及一个使用 USDC 的执行阶段),因此紧随验证阶段之后进行估算最为准确。
一个可能的改进是:让 entry point 在 simulateHandleOp 期间返回一个布尔值,表示执行部分是否回滚。这样 bundler 就可以在链下运行二分查找,而无需代理合约和状态覆盖。然而,这种二分查找的效率会较低,因为每次迭代都会运行一遍验证阶段。
这促使我们将上文尝试 3 中由代理合约完成的二分查找,直接以其功能的形式加入到 entry point 合约中(伪代码):
我们对上述方案的实现(作为一个代理合约)即将开源。我们认为,将基于二分查找的估算功能内置到 entry point 合约本身中,可能会为 ERC-4337 用户带来益处。
敬请关注我们即将开源的 bundler 实现!🦀
继续阅读
本系列共 4 篇文章,后续文章将探讨我们在 gas 估算方面遇到的挑战,以及我们工程团队所实施的解决方案。
Alchemy 新闻通讯
第一时间获取发布信息
订阅我们的新闻通讯
获取 Alchemy 的最新产品更新和资源
填写您的电子邮箱地址即表示您同意接收我们的营销通讯和产品更新。您确认 Alchemy 按照我们的隐私声明处理我们收到的信息。您可以随时取消订阅。


