コンテンツへスキップ
0%

ERC-4337のガス見積もりの仕組み

最終更新日: 2023年7月11日2分で読めます

AlchemyがERC-4337のBundlerである「Rundler」を構築する中で、最も正確に実装するのが困難だったコンポーネントがuser operationのガス見積もりでした。本記事では、ユーザーに正確なガス見積もりを提供する上で直面した障害と、現在採用している解決策について説明します。

これはERC-4337のビルダー向けの技術的な概説です。ERC-4337形式のアカウント抽象化が初めての方は、まずAccount Abstraction入門シリーズを読むことをお勧めします。

なぜERC-4337のuser operationにおいてガス見積もりが重要なのか

正確なuser operationのガス見積もりを提供することは、ERC-4337のユーザー体験にとって重要です。ガス見積もりが低すぎると、user operationはシミュレーション中にrevertするか、さらに悪い場合はオンチェーンの実行フェーズでrevertし、ユーザーがrevertしたoperationのガス代を負担することになります。ガス見積もりが高すぎると、コストのためにユーザーがoperationの送信をためらったり、送信できなくなったりする可能性があります。

正確さは重要ですが、ガス見積もりが100%正確である必要はなく、誤差が常に過大見積もり方向に(ただし過大になりすぎない範囲で)出ていれば問題ありません。ERC-4337ではガスフィールドはlimitとして表現されており、ユーザーはオンチェーンで消費しなかったガス分が返金されます。したがって、ガス見積もりは実際のoperationコストには影響しません。

💡 *ただしpreVerificationGasは例外です。詳細は後述します。

ERC-4337のガスフィールドの定義

user operationにおけるガスフィールドと、ERC-4337仕様における定義は以下の通りです。

  • preVerificationGas: pre-verificationの実行とcalldataに対してbundlerに補償するために支払うガス量。
  • verificationGasLimit: verificationステップに割り当てるガス量。
  • callGasLimit: メインの実行呼び出しに割り当てるガス量。
  • maxFeePerGas : ガスあたりの最大手数料(EIP-1559のmax\_fee\_per\_gasに類似)。
  • maxPriorityFeePerGas: ガスあたりの最大優先手数料(EIP-1559のmax\_priority\_fee\_per\_gasに類似)。

ERC-4337のガス計測

ERC-4337のbundlerは、entry pointコントラクトにbundleトランザクションを送信するコストを先に支払います。entry pointは各user operationで使用されたガスを計測し、それに算出された手数料を乗じ、user operation完了後にこの値をbundlerに補償します。

実際の計算は次のようになります。*

💡 *簡略化のため、これはEIP-1559をサポートするネットワークのみを対象としています。

preVerificationGasはそのまま加算される一方、verificationとcall gasは「計測」されます。つまり、それらのガス使用量はentry pointがオンチェーンで測定し、limitに達するまで実際に使用した分だけ課金されます。verificationまたはcallフェーズのいずれかでlimitに達した場合、operationはrevertします。

entry pointは、トランザクションの一部としてガスを計測するため、ガスの払い戻しを処理する手段を持ちません。ガスの払い戻しはトランザクション完了後に発行され、Bundlerに直接返還されます。これはユーザーにとって追加のコスト要因であり、bundlerにとっては潜在的な収益源です。

User operationのフロー

user operationを送信する一般的なフローは以下の通りです。

  1. sender、nonce、initCode、callDataを設定した部分的なuser operationを構築する。 a. あわせて、signatureとpaymasterAndDataに「ダミー」値を設定する。
  2. eth\_estimateUserOperationGasを使ってbundler RPC経由でこの部分的なuser operationのガスを見積もる。 a. 戻り値からpreVerificationGas、verificationGasLimit、callGasLimitを設定する。
  3. operationに必要なガス手数料を見積もり、maxFeePerGasとmaxPriorityFeePerGasを設定する。 a. このステップはステップ1や2に依存せず、いつでも、あるいは並行して実行できる。
  4. (任意) 署名のためにuser operationをスポンサーとなるpaymasterに送信する a. 戻り値からpaymasterAndDataを設定する。
  5. 上記のuser opに署名し、signatureを設定して、eth\_sendUserOperation経由でbundlerに送信する。

本記事ではステップ2と、bundler RPCメソッドであるeth\_estimateUserOperationGasに焦点を当てます。

User operationのガス見積もり

eth\_estimateUserOperationGasは、ERC-4337仕様に従いbundlerがサポートしなければならないRPCメソッドです。

その定義:

UserOperationのガス値を見積もる。ガスlimitとガス価格を省略できるUserOperationが与えられた場合、必要なガスlimitを返す。signatureフィールドはウォレットによって無視されるため、operationにユーザーの承認は不要となる。ただし、「semi-valid」な署名(例えば正しい長さの署名)を入れる必要がある場合もある。

*パラメータ**: eth_sendUserOperationと同じ。ガスlimit(および価格)パラメータは省略可能だが、指定された場合は使用される。maxFeePerGasとmaxPriorityFeePerGasはデフォルトでゼロとなり、アカウントとpaymasterのいずれにも支払いは要求されない。*

戻り値:

  • *preVerificationGas** このUserOperationのガスオーバーヘッド*
  • *verificationGasLimit** このUserOperationのvalidationで実際に使用されたガス*
  • *callGasLimit** inner accountの実行に使用される値*

PreVerificationGasの計算

PreVerificationGasは、entry pointが計測しないガス使用量をすべて捕捉するために使われ、bundlerにこの分のガスを補償します。

単純なケースでは、これは3つの別々の計算に分解できます:

  1. bundleトランザクションのintrinsic gasにおけるそのoperationの取り分。 a. bundlerはこれを事前に決定するためにbundleサイズを仮定する必要がある点に注意。
  2. calldataのガスコストにおけるそのoperationの取り分。 a. これはoperationのサイズとバイト構成に直接起因する。
  3. entry pointが計測されない範囲で負う実行ガスオーバーヘッドにおけるそのoperationの取り分。 a. これはentry pointコントラクトのガス使用量分析をオフチェーンで行い、user operationごとに割り当てることで決定される。

preVerificationGasはlimitではないことに注意してください。つまり、このフィールドに設定された値は常に全額がbundlerに支払われます。このフィールドは慎重に扱ってください。誤った値を設定すると、必要以上の手数料をbundlerに支払ってしまう可能性があります!

preVerificationGasについてのより詳細な内容は、StackUpチームによるこの詳細な分析をご覧ください。

bundlerは通常、「pre-check」フェーズ(トレースベースの完全なシミュレーションの前に実行するチェック)の間にpreVerificationGasの計算を実行します。

あるoperationのpreVerificationGasがbundlerが計算した値より低い場合、そのoperationは拒否されます。これはbundlerの実装間の非互換性の原因となる可能性があります。

あるbundler実装Lがより低いpreVerificationGasの値を見積もり、別の実装Hがより高い値を見積もって(かつ要求して)いる場合、HはLで見積もられたoperationを拒否します。

この非互換性がP2Pメンプールにどのような影響を与えるかはまだわかっていませんが、ユーザーはbundleされる可能性を最大化するために、少し高めのpreVerificationGasを見積もるインセンティブを持つ可能性が高いでしょう。

AlchemyのERC-4337 bundlerがガスlimitを見積もる方法

ガスlimitフィールドであるverificationGasLimitとcallGasLimitの正確な見積もりを提供することは重要です。目標は次のように述べられます。過大見積もりのみを確実に行いつつ、それでもユーザーがoperationの送信をためらわない程度に抑えて、これらのフェーズで使用されるガスを見積もる関数を提供すること。

Rundlerでこの関数を提供するために行ってきたさまざまな試みについて見ていきましょう。

試み1: eth\_estimateGas

標準のEthereum JSON-RPCには、トランザクションのガスを見積もるのに便利なメソッドが用意されています。eth\_estimateGasを使ってみましょう!

この方法は次のように動作します:

  • verificationGasLimit : entry pointの simulateValidation 関数を呼び出し、eth\_estimateGas に渡す
  • callGasLimit: entry pointの innerHandleOp 関数を呼び出し、eth\_estimateGas に渡す

これは一見うまく機能するように見えます。simulateValidationとinnerHandleOpのいずれも各フェーズの計測部分を正確に捉えているわけではありませんが、厳密には上位集合であり、若干の過大見積もりになります。

これらの呼び出しは完全に独立して行われますが、オンチェーンでは単一のトランザクションとして実行される点に注意してください。この方法では、simulateValidationの結果はinnerHandleOpに反映されず永続化されません。

innerHandleOpがsimulateValidationの間に行われる何かに依存している場合はどうなるか?

これはアカウントの初回デプロイ時や、一部の高度なvalidationスキームで発生し得るケースです。factoryメソッドはvalidationフェーズ中に呼び出され、アカウントコントラクトをデプロイします。実行フェーズにはこのコントラクトがデプロイされている必要があり、そうでない場合innerHandleOpはrevertし、見積もりの試み自体が失敗します。

両方のガスlimitフィールドを見積もる何らかの方法が必要ですが、その際に実行フェーズの見積もりがvalidationフェーズと同じ呼び出し内で実行されることを保証する必要があります。

次の試みに進みましょう…

試み2: シミュレーションして計測する

ERC-4337 v0.6の実装は、validationフェーズと実行フェーズを単一の関数呼び出しに組み合わせたsimulateHandleOp メソッドを提供します。その戻り値にはpreOpGasが含まれており、これはvalidation中に使用されたガスとpreVerificationGasの合計です。また、user opが支払った合計金額であるpaidも含まれます。

callGasLimitを求めるには、paidをガス手数料で割ることでガス使用量に変換できます。bundlerは、既知のbase feeを持つ(直近の)ブロック高にシミュレーションを固定し、priority feeを0に設定することで、ガス手数料を決定できます。いくつかの単純な変換を経て、すべてのガスフィールドを計算できます。この方法も、計測されないentry pointロジックの一部が使用ガスに帰属するため、過大見積もりになる可能性があります。

もう一つの妥当な方法は、simulateHandleOp呼び出し全体で使用されたガスを計測し、そこからvalidationフェーズで使用されたガスを差し引くことで、callフェーズで使用されたガスを計測する方法です。

simulateHandleOpで使用されたガスを計測し、それを戻り値に付加するヘルパーコントラクトをデプロイすることができます。この方法も、前述のものと同じ過大見積もりの問題を抱えています。

過大見積もりに厳密にこだわる方のために、さらに精緻化することもできます。

entry pointのsimulateHandleOpメソッドをトレースしてガスを計算できるか?

bundlerは、オンチェーンでの無効化から自身を守るために必要なシミュレーションチェックを実行するため、トレース呼び出しを実行する能力を持っていなければなりません。この機能を使ってガスを見積もることはできるでしょうか?

この方法は次のように動作します:

  1. entry pointのsimulateHandleOpメソッドをトレースする。これは都合よく「number marker」方式を使っているため、トレーサーは自分が実行のどのフェーズにいるかを正確に把握できる。
  2. トレーサー内で、各フェーズの開始時点と最終時点に残っているガス量を記録する。
  3. これらの値から、各フェーズで使用されたガス量を計算する。

素晴らしい!少し高コストなトレースを使って、使用されたガス量を正確に計算できました。

しかしそう簡単にはいきません。上記のいずれの方法を使っても、特定のコントラクト呼び出しに対してoperationがガス切れを起こすことがあります。

なぜでしょうか?

その答えを探るために、Rethがどのようにガスを見積もっているかからヒントを得ることができます。二分探索に気づきましたか?あそこでは何が起きているのでしょうか?Rethはなぜ上記と同様のトレース方式を使わないのでしょうか。同じ情報にアクセスできるにもかかわらず。

実は、EVMの関数呼び出しにおいては、使用されたガス ≠ 必要なガス、なのです。

なぜこうなるのかの概要は、Sergio Lernerの「The Dark Side of Ethereum 1/64th CALL Gas Reduction」という記事で解説されています。この理由の中で最も重要なのが「63/64thルール」(あるいは話す相手によっては「1/64thルール」)と呼ばれるものです。

このルールは、EVMが各関数呼び出しに対して残りガスの63/64のみを転送するというものです。この結果、最終的に消費される量よりも少し多くのガスを事前に用意する必要があります。これは呼び出しごとに発生するため、呼び出しスタックが深いvalidation/実行フェーズでは、取り置かれるガスを賄うために事前に大量のガスを確保する必要が生じる場合があります。

すべての潜在的なuser operationに対して機能するソリューションが欲しいので、ガス使用量の測定値をガスlimitの見積もりに使う試みはすべて無効となります。次の試みでは、EVMクライアントからヒントを得ましょう。

💡 トレース関数の詳細な分析を行い、63/64thルール(や他のEVMの細かい仕様)を考慮に入れて、使用されたガスからガスlimitを逆算することは可能かもしれません。私たちはまだこの方向性を検討していません。

試み3: 二分探索

ここで車輪の再発明をする必要はありません。上記で説明した複雑さに対処するため、ノードクライアントはガス見積もりの際に二分探索を使い、トランザクションを成功させる最小のガス値を見つけています。user operationについても同様のことができます!

このアプローチは次のようになります。

まず、二分探索には同じ関数への多数の呼び出しが必要になるため、その関数が高いパフォーマンスを持つことを確保したい点に注意してください。これにより、トレースを使う方法はここでは現実的な選択肢から外れ、eth\_callに頼る必要があります。

verificationGasLimitを見積もるには:

  1. MAX\_VERIFICATION\_GAS(bundlerの設定であり、validationフェーズで使用できる最大ガスを定義する)でのsimulateHandleOpの試行を実行し、そもそも成功が可能かどうかを確認する。 a. この実行中は、計算を節約するためにcallGasLimitを0に設定する。
  2. ガス切れを起こさない最小のverificationGasLimit値を絞り込むため、simulateHandleOpで二分探索を実行する。 a. validationフェーズがガス切れを起こすと呼び出しはrevertする。より高いガスlimitで呼び出しが成功することは既に確認済みのため、revertが発生した場合は二分探索の下限を引き上げることができる。

ここでの一つの良い最適化は、100%正確な見積もりが必要ないという点に着目することです。bundlerは、どれだけ正確さに近づけたいかを選択でき、その範囲内に収まった時点で二分探索を終了できます。ただし常に高い側に誤差が出るようにします。これにより、多くの呼び出しラウンドを節約できます。例えばRundlerでは、この誤差範囲を1Kガスに設定しており、10回分の反復を節約しています。

もう一つ、数回の反復を節約するための最適化は、探索のより良い開始点を決定することです。(1)で使用されたガスにスカラー値を乗じ(例えばRethは3倍を使用)、それを二分探索アルゴリズムの初期推定値として設定します。

callGasLimitを見積もるには:

  1. verificationGasLimitをMAX\_VERIFICATION\_GASに設定する
  2. MAX\_EXECUTION\_GAS(仕様の一部ではないが、bundlerがDoSから自身を守るために必要)での試行を実行し、実行の成功が可能かどうかを確認する。

この見積もり方法が破綻するのはここです。revertはentry pointによって捕捉され、ログとして発行されますが、eth\_callには発行されたログを調べる手段がありません。また、各試行のたびにvalidationを完全に実行することで計算を無駄にしてしまいます。

このcallGasLimit二分探索を実行する方法を提供できるでしょうか?

💡 トレースを使えばこのrevertを検知することは可能ですが、コストの観点からそれは避けたいところです。もしbundlerがノードクライアントを完全に制御でき、ネイティブトレーサーを使用できるのであれば、この判断を再検討する価値はあるかもしれません。

解決策: Solidityでの二分探索

verificationGasLimitの見積もり手順を基に、この方法の核となるアイデアは、実行部分が失敗したかどうかを捕捉できるロジックをエンコードした上で、SolidityでcallGasLimitの二分探索を実行することです。

制約条件を定義しましょう:

  1. アカウントコントラクトが確実にデプロイされるよう、二分探索はvalidationの実行後に行わなければならない a. 望ましいが必須ではない制約として、無駄な計算を減らすため、validationは一度だけ(あるいは回数を制限して)実行するべきである。
  2. execute呼び出しはentry pointから開始されなければならない。多くのアカウント実装は、msg.senderがハードコードされたentry pointアドレスと一致する場合にのみexecute関数の呼び出しを許可する。
  3. 二分探索は最終結果を返さなければならない。
  4. すべてのentry point関数は通常通りに動作しなければならない。

私たちがたどり着いた解決策は、DELEGATECALLを使ってentry pointコントラクトに転送しつつ、追加のロジックを付加するproxyコントラクトを使うことです。見積もりの間、eth\_callオーバーライドを使って元のentry pointコントラクトをランダムなアドレスに移動し、それをproxyコントラクトで置き換えます。これにより制約(2)と(4)が満たされます。

上記で述べた通り、entry pointは実行フェーズの結果を隠します。代わりに標準のexecute呼び出しをスキップし(callGasLimitを0に設定することで)、simulateHandleOpへのtarget/targetCallData引数を利用します。これはvalidationの後に実行され、情報を返すことができるため、制約(1)と(3)を満たします。以下にentry pointのコード(コメント付き)を示します:

カスタムターゲット呼び出しでは、proxyコントラクト上のestimateCallGasというメソッドを呼び出し、その呼び出しデータには以下がエンコードされます:

  1. アカウントとそのcall data(元のuser operationから)
  2. 二分探索のパラメータ

proxyは元のentry pointのアドレスに配置されているため、estimateCallGasはアカウントのexecute関数を呼び出し、呼び出しが成功したかガス切れになったかを判定し、成功するまで二分探索アルゴリズムを実行できます。

これで、二分探索をeth\_call内で実行し、callGasLimitの正確な見積もりを得られるようになりました!

実際には、Rundlerはこの単一のeth\_callを複数の呼び出しに分割し、それぞれに最大のグローバルガスlimitを設定することで、ノードの最大call gas limitを回避しています。各呼び出しは、次の反復でガス切れになるまで二分探索の反復を実行します。

💡 この方法では、innerHandleOpの間にentry pointが課すわずかなオーバーヘッドを見落とします。このオーバーヘッドは静的なもので、見積もり結果に加算されます。

entry pointの潜在的な変更

上記で説明したガス見積もりプロセスをより実装しやすく、ユーザーにとってより高パフォーマンスにできる、entry pointの潜在的な変更について検討してきました。これは容易に解決できる問題ではないため、他のbundlerやERC-4337コミュニティのメンバーからアイデアがあればぜひお聞きしたいと思っています!

ガス見積もりの際、validationフェーズが実行フェーズの前に実行されることを確実にしたいと考えています。実行フェーズはvalidationフェーズ中に設定された状態に依存する場合があるため(例えばUSDCのpaymaster転送と、USDCを使用する実行フェーズ)、直後に見積もるのが最も正確です。

一つの潜在的な改善策は、simulateHandleOpの間に実行部分がrevertしたかどうかを表すbool値をentry pointが返すようにすることです。そうすればbundlerは、proxyコントラクトや状態のオーバーライドなしにオフチェーンで二分探索を実行できます。ただし、この二分探索は各反復でvalidationフェーズを実行することになるため、非効率的です。

そこで、上記の試み3でproxyコントラクトが行っていた二分探索を、entry pointコントラクトに直接その機能を追加する方向へと発展させます(疑似コード):

上記の実装(proxyコントラクトとして)はまもなくオープンソース化される予定です。二分探索による見積もりがentry pointコントラクト自体の一部となることは、ERC-4337ユーザーにとって有益だと考えています。

近日公開予定のオープンソースbundler実装にご期待ください! 🦀

続きを読む

この4部構成シリーズの次の記事では、私たちが直面したガス見積もりの課題と、エンジニアリングチームが実装した解決策について取り上げます。

Alchemy Newsletter

リリース情報をいち早く受け取る

ニュースレターに登録する

Alchemyの最新のプロダクト情報とリソースをお届けします

A
O
D
+
80,000人以上が購読中

メールアドレスを入力すると、当社のマーケティング情報およびプロダクト最新情報の受信に同意したことになります。Alchemyが受け取った情報をプライバシー通知に従って取り扱うことに同意するものとします。購読はいつでも解除できます。