【ERC4337(1)】EntryPointのhandleOps()の処理を理解する、Paymasterを指定してガス代について検証する

Web3.0, ブロックチェーン

お疲れ様です。
次世代ビジネス推進部の荻原です。

今回は、ERC4337についてです。
以前も

の2つの記事を書いたのですが、さらに深く調べたことをまとめました。以前書いたものはまだまだ理解が甘かったな…という反省を込めて書いていきます。

今回は3本立てです。最終的にUserOperationの作り方とどのように実行されるかを理解した上で、Pythonで実行してみることをゴールにします。

第1回目の今回は、UserOperationを実行するEntryPointコントラクトの handleOps()のソースコードを読んでわかったことをまとめます。ここを理解することでUserOperationの作り方とどのように実行されるかがわかるようになるかと思います。 handleOps()の中でPaymasterに関する実装もあるので、UserOperation実行時のガス代についてもコードを読んだ上で実際に検証してみました。

目次

1. handleOps()について
2. _validatePrepayment()(資金準備のチェック)
3. _validateAccountAndPaymasterValidationData()(PaymasterAndDataのsignatureとタイムスタンプの検証)
4. _executeUserOp()(callDataの実行)
5. _compensate()(ガス代の払い戻し)

1. handleOps()について

EntryPointの handleOps()のコードを追ってみます。
eth-infinitismから提供されているaccount-abstractionのEntryPointを使います。

主に _validatePrepayment()_validateAccountAndPaymasterValidationData()_executeUserOp()_compensate()の4つを実行しています。

_validatePrepayment()_validateAccountAndPaymasterValidationData()の2つは関数名の通りUserOperationの各値の検証やinitCodeの実行をします。 _executeUserOp()はUserOperationのcallDataに基づいて処理を実行します。メインの部分と言えます。 _compensate()_executeUserOp()でかかったガスを指定したアカウント( beneficiary)に払い戻します。

それぞれについて見ていきます。

2. _validatePrepayment()(資金準備のチェック)

ガス代支払いのための資金が準備できているかをチェックしています。

部分的にピックアップします。

次の箇所からGas関連の各値(preVerificationGas 、verificationGasLimit、callGasLimit、maxFeePerGas、maxPriorityFeePerGas)には type(uint120).max == 2^(120)−1を超える値は指定できないことがわかります。

そして以下でガス代の支払いのために準備しておくべき金額を取得しています。

準備とは、 EntryPoint.depositTo()を実行して、EntryPointにガス代の支払いに使用するための資金を送っておくことです。実装はEntryPointが継承しているStakeManagerにあります。

UserOperation実行時のガス代は deposits[<address>].depositに格納されている分の資金から支払われます。 <address>はPaymasterを使用する場合はPaymasterのaddress、使用しない場合はSmart Contract Accountのaddress( sender)です。

EntryPoint.depositTo()は次のように実行します。Truffleでの実行を例に取ります。

この送金する額がどのくらい必要かは _getRequiredPrefund()の実装を見るとわかります。

少しわかりやすく書き直します。

UserOperationの callGasLimitverificationGasLimitpreVerificationGasmaxFeePerGasの値を決めていれば上記の式からオフチェーンで計算することが可能です。
requiredPrefundを超える値を EntryPoint.depositTo()で送金すればOKです。

_validatePrepayment()の続きを見ていきます。

_validateAccountPrepayment()はUserOperationの検証をします。Paymasterを使わない場合はここでSmart Contract Accountのdepositが足りているかチェックします。Paymasterを使用する場合はこの関数内ではまだdepositのチェックは行われません。

具体的に見ていきます。

initCodeが 0xでなければ _createSenderIfNeeded()でSmart Contract Accountがdeployされます。

具体的には

の部分です。
SenderCreatorはEntryPointが継承しているコントラクトです。このときにGasとして verificationGasLimitが指定されています。

という部分でsenderとなるSmart Contract Accountの validateUserOp()を実行してUserOperationの検証をしています。 validateUserOp()は自由に実装できるため、それ次第でどのようなUserOperationが通すのかを決めることができます。実行時のGasとしては verificationGasLimitが指定されます。

ここまでで verificationGasLimitはSmart Contract Accountのdeployと validateUserOp()に使用されることがわかりました。

_validatePrepayment()に戻ると、最後の方の以下の部分から verificationGasLimitはこの関数内でここまでに使われるガス以上の値が必要なことがわかります。

3. _validateAccountAndPaymasterValidationData()(PaymasterAndDataのsignatureとタイムスタンプの検証)

PaymasterAndDataに含まれるsignatureとタイムスタンプの検証をします。
特に説明することはないのでコードは省略します。

ここまででUserOperationのcallDataを実行するための検証や準備が終わりました。

4. _executeUserOp()(callDataの実行)

callDataの実行をします。

メインとなっているのは innerHandleOp()です。

下記の箇所からこの時点でのGasの残りが callGasLimit + mUserOp.verificationGasLimit + 5000より小さいとエラーになるので callGasLimitverificationGasLimitに大きすぎる値を指定したりGasを小さく設定してしまうと上手くいきません。
callGasLimitverificationGasLimitはここまでの箇所である程度大きくないといけない制約があったのでGasが足りない場合は handleOps()を実行するトランザクションでGasを大きめに指定すると良かったです。

callDataが 0xでなければ次の箇所で実行されます。Gasとしては callGasLimitが指定されています。

最後の _handlePostOp()はSmart Contract AccountまたはPaymasterが前払いしていた資金から余った分を戻しています。

5. _compensate()(ガス代の払い戻し)

_executeUserOp()でかかったガス代を beneficiaryに払い戻します。このあたりについてトランザクションのガス代との関係性がよくわからなかったので検証してみました。

5-1. コードを見てみる

_executeUserOp()で返された値の合計collectedが払い戻しの分です。これがどのように計算されているのか追ってみます。

4章で説明した、 _executeUserOp()とその中で実行されている innerHandleOp()_handlePostOp()について、大体以下のような実装になっていました。 _executeUserOp()については innerHandleOp()_handlePostOp()に被らない部分のGasについてのみ含まれるようになっています。

opInfo.preOpGas_validatePrepayment()内の以下で求められています。

つまり、

です。

5-2. 実際に実行した結果を見てみる

以下についてUserOperationの実行前後で比較してみます。

  • handleOps()を実行するトランザクションに署名するEOA(Tx署名EOAと呼びます)のbalance
  • handleOps()beneficiaryでアドレスを指定するEOA(beneficiaryと呼びます)のbalance
  • UserOperationに署名するEOA(signerと呼びます)のbalance
  • Paymasterのdeposit

ちなみに、Paymasterのbalanceに関しては今回使うことはないので +-0です。

結果は

  • Tx署名EOA: -655695001049112
  • beneficiary: +624450000999120
  • signer: +-0
  • Paymasterのdeposit: +624450000999120

でした。具体的な値はそのときどきで変わってきます。

baseFeePerGasが 8wei、maxPriorityFeePerGasが 5000000000weiだったので1Gasあたりのガス代は 5000000008weiです。この値で上記の値を割ると

  • Tx署名EOAは 655755001049208 / 5000000008 = 131139Gas分支払っていました
  • beneficiaryには 624450000999120 / 5000000008 = 124890Gas分支払われていました
  • Paymasterのdepositからは 624450000999120 / 5000000008 = 124890Gas分支払われていました

トランザクション実行にかかったGasは 131139だったので、Tx署名EOAと一致しています。これは通常通りマイナーに支払われていると思います。

beneficiaryについて先ほど読んだソースコードから推察すると、 _validatePrepayment()innerHandleOp()_handlePostOp()_executeUserOp()で使用されたGas分がPaymasterのdepositからbeneficiaryに支払われたことになると思います。

これらの差分 131139 - 124890がそれ以外で使われたGasということになります。

ここまででわかったことを踏まえて、Paymasterがスポンサーになるということはどういうことかをまとめてみます。

  • Paymasterのbalanceから直接UserOperation実行のトランザクションのガス代が支払われるわけではない
  • 事前にEntryPointのPaymasterのdepositに任意のアカウントから送金しておき、そこから handleOps()実行時にかかったガス代の一部が引かれる
  • その分は、 handleOpes()実行時にbeneficiaryとして指定したアカウントに送金される
  • 通常のトランザクションと同様にトランザクションを発行したアカウントからトランザクション全体でかかったガス代は引かれる

さいごに

これでEntryPointの handleOps()の全体的な流れとUserOperation実行時のガス代についてわかったかと思います。次回は実際にUserOperationを実行してERC4337のSmart Contract Accountから同様に作成したSmart Contract AccountへNFTをtransferするための準備を行っていきます。

参考