【ERC4337(3)】UserOperationをPythonで実行する

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

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

今回は【ERC4337(2)】EntryPoint、Paymaster、Smart Contract Account、ContractFactoryの実装の仕方の続き、第3回目です。

前回の準備を踏まえて実際に handleOps()をPythonで実行してみます。”Pythonで”というところがポイントで、JavaScriptやTypeScriptのERC4337用のライブラリは使用せず、web3.pyeth_accounteth_abiを使うので、ある程度どのような処理が行われているのか中身がわかりやすいかと思います。

handleOps()は複数のUserOperationを実行できるので以下をまとめて1回でやってみます。

  1. Smart Contract Account1を作成
  2. Smart Contract Account2を作成、Smart Contract Account2宛にmint
  3. Smart Contract Account2→Smart Contract Account1にtransfer

UserOperationを実行するには、まずUserOperationを作成してそれを引数にとってeth_sendRawTransactionで handleOps()を実行します。

UserOperationは

で構成されているのでこれらを1つずつ求めていきます。

コード全体はAppendixに載せました。

実行環境は以下です。全体的にバージョンが古いのは今回は検証なのでご了承ください。

目次

1. Account1を作成するUserOperationの作成
2. Account2を作成、Account2宛にmintするUserOperationの作成
3. Account2→Account1にtransferするためのUserOperationを作成
4. handleOps()の実行
5. 結果

1. Account1を作成するUserOperationの作成

Smart Contract AccountのContractFactoryの deploy()を実行するためのUserOperationを作成していきます。

まず諸々初期化します。

entry_point_addresspaymaster_addressaccount_factory_addressnft_contract_addressには事前にdeployしていた各コントラクトのaddressを入れておきます。 admin_addressprivate_keyはトランザクションに署名するEOAのaddressとそのprivate keyです。

transaction_optionsではトランザクション発行時のoptionを設定しています。Gasはデフォルトだと検証中に足りなくなることがあったので多めに設定しています。

entry_point_contract_jsonaccount_contract_jsonaccount_factory_contract_jsonnft_contract_jsonにはそれぞれEntryPoint、Account、ContractFactory、NFTのコントラクトのABIとbytecodeがdict型で格納されています。

handle_opsに3つのUserOperationを格納します。

次にinitCodeを作成します。これはContractFactoryの deploy()を実行するためのものです。initCodeは次の形なのでこれを求めます。

今回はContractFactoryは1つなので、 getLength()の値が一意で取れます。そのためそれをsaltとして使うこととします。実行したい関数名と引数をencodeしてContractFactoryのcontract addressとくっつけて完成です。

次にsenderを求めます。先ほど作成した deploy()の引数 constructor_argsをそのままContractFactoryの getNewAddress()に指定します。

callDataで実行したいものは今回はないので 0xです。

nonceはEntryPointの getNonce()という関数で取得可能です。

トランザクション発行時のnonceはトランザクションの署名者が発行したトランザクション数になりますが、こちらのnonceは全く別の値で、次の形でEntryPointが保持しています。

指定したsenderについてそのEntryPointで実行したUserOperation数になります。今回の場合で言うとsenderをinitCodeでdeployする際が0、その後deployされたコントラクトでメソッドを実行する度にインクリメントされます。

paymasterAndDataは今回はPaymasterのaddressです。Paymasterのコントラクトでsignatureやタイムスタンプの検証を行っている場合は20バイト以降に続けます。

callGasLimitcallGasLimitverificationGasLimitpreVerificationGasmaxFeePerGasmaxPriorityFeePerGasの値は今回は適当に、足りなくならないように多めに設定します。

これでsignature以外のUserOperationは準備できました。

あとはsignatureを求めます。まずUserOperationHashを求めてそれに署名します。UserOperationHashはsignature以外のUserOperationとEntryPointのaddress、chain idで計算できます。

ここの実装はこちらを参考にしています。hash関数は似たものが色々あり、hash化の仕方もややこしくて自分はここでとても苦戦しました。

一例として似ているものを並べてみました。

encode_abi_packed()encode_abi()は似ていますが計算結果が異なります。 Web3.solidityKeccak())Web3.keccak(encode_abi_packed())と同じですが、今回正しいのは Web3.keccak(encode_abi())なので Web3.solidityKeccak())は使えません。自分は「Solidity」と付いていたので迂闊にこの関数を使ってしまったのですが、違っていたので気をつけてください。

また、 Account.signHash()web3.eth.account.sign_message()は同じ結果になります。 Account.signHash()はhexのstrを指定する場合、 defunct_hash_message()で一度変換していないと結果が変わります。

UserOperationのhash化はEntryPointとAccountの実装と擦り合わせる必要があります。

何はともあれUserOperationHashは完成したのであとはこれに署名するだけです。署名者はSmart Contract Accountの validateUserOp()で承認する想定のEOAです。

handleOps()の第1引数はtupleのlistなのでその形で handle_opsに格納します。

2. Account2を作成、Account2宛にmintするUserOperationの作成

だいたいは1つ目と同じなので部分的に説明します。

新しくAccountを作成するのでsaltは1つ目のsalt + 1です。

今回はAccountの execute()を介してNFTの mint()を実行するためのcallDataを作成します。callDataの形式は以下です。

execute()は第1引数(dest)でターゲットとなるNFTのcontract addressを、第2引数で送金額を(今回は0です)、第3引数(func)で実行したい関数名と引数をencodeしたものを取ります。そのため、まず mintの実行に関してencodeし、その結果を用いて executeについてencodeするという2重の構造になっています。

1つ目のUserOperationとsenderが変わるので新しいsenderについてnonceを取得します。nonce1も同様ですが、わざわざこのように書かなくても0とわかっているので0を直接指定してもOKです。

3. Account2→Account1にtransferするためのUserOperationを作成

3つ目は新しいAccountをdeployしないのでinitCodeは 0xです。

callDataはmintと同じ要領でsafeTransferFrom用にします。

nonceは2つ目のUserOperationとsenderが同じなのでそれに+1します。

4. handleOps()の実行

ここは通常のトランザクション実行と変わらないと思います。

実行していくのですが、Gasを多めに設定したため、自分の環境だとGanache起動時にCall Gas LimitとBlock Gas Limitも多く設定する必要があっため次のようにGanacheを起動しました。

5. 結果

UserOperationの実行結果については注意点がありました。

通常のトランザクションがrevertされる等で失敗した場合は先ほど実装した buildTransactionsend_raw_transactionで例外が投げられます。しかし、UserOperationのcallDataの実行で失敗した場合は正常終了します。そしてトランザクションのReceiptでstatusが取れますが、それもsuccessとなります。

PolygonScanではcallDataの実行に失敗したトランザクションは次のようになります。

図1.png

UserOperationの実行に失敗した場合、eventとしてはUserOperationRevertReasonが取れるのでそれを使うとcallDataの実行に成功したかが判断できると思います。

今回はTruffleを使ってtokenのownerが意図したものになっているかを確認することで結果をチェックしようと思います。使用するaddressは次の通りです。

Truffleのconsoleから確認しました。

まずはdeployされたはずの2つのAccountコントラクトが存在しているか確認します。

EntryPointの値が正しく取れているので問題なさそうです。

次にNFTのmintとtransferに成功したか見てみます。token_id=1のownerが最終的にAccount1( 0x4Fb6124B2D1849Fc44477314D4dd9505F58D83e1)であればOKです。

上手くいっていそうです 👏

今回実行した内容を少し振り返ってみます。
NFTのコントラクトから見ると、mintやtransferを実行したのはAccount2であり、コントラクトということになります。まさにAccount Abstractionです。ただ、実際にはAccount2を介してmintやtransferを実行したのはEntryPointで、トランザクションを発行したのは署名したEOAであり、ERC4337が完全にはAccount Abstractionを実現しているわけではないことがよくわかります。

参考

Appendix

今回実装したコード全体を載せておきます。