【Account Abstraction】UserOperation周りの具体的な実装について
前置き
最近話題のマイナウォレットについて実装をチェックしてたところ、自分が全然UserOperation周りを理解してないことに気が付いたので、その備忘録です。
このため、本記事ではマイナウォレット、及びAAのサンプル実装をベースに記載します。
この記事で扱うこと・扱わないこと
扱うこと
- UserOperationの作成方法
- UserOperationの検証部分
扱わないこと
- 細かい暗号技術
- ガス代/Paymaster周り
UserOperationをBundlerに渡すまで
まずは以下画像のUserOperation→Bundlersまでの道のりを書いていきたいと思います。
そもそもUserOperationとはどういったものなのでしょうか。
引用:https://docs.stackup.sh/docs/erc-4337-overview
Stackupのサイトを見てみると、分かりやすい表が載っていました。
senderやnonceあたりはなんとなくイメージが付きますが、initCode, signatureが謎なので詳しく見ていきます。
initCode
上述の画像では、"senderがまだon-chain上にない場合、デプロイに使用されるコード"と記載がありました。
つまり、ウォレットアカウントを作成するためのコードです。
マイナウォレットでは以下の関数で取得しています。
まず、57行目でmodulusというユニークなKey(マイナンバーの公開鍵)をもとにcodeを取得します。
取得したcodeが"0x"であれば、未デプロイと判断して、FactoryコントラクトのcreateAccount関数を返すようになっています。
これが実行されると、ウォレットコントラクトが出来上がるわけですね。注意としては、現時点ではまだ実行されていません。
Signature
上述の画像では、”認証の時に使われるデータ”と書かれています。(名前からして当然ですが)
Signatureについては、Stackupのサイトの以下に記載があります。
これによると、"全てのSignatureは、署名されたuserOpHashであるべき"と書いてあります。
同ページにuserOpHashについても説明があったので見ていきます。
userOpHashは、以下の3つの値をkeccak256によってハッシュ化したものです。
- UserOperation全体(Signatureは除く)
- EntryPointのアドレス
- chainID
こうしてできたuserOpHashに署名をしたものがSignatureとなります。
このため、1でSignatureが除かれるのは当然ですね。
また、一言で署名と書きましたが、どのような署名を行うかは実装者の自由です。
マイナウォレットでは、ここがマイナンバーカード内の秘密鍵による署名となっています。
ここで行われた署名は、後々コントラクトで検証することになります。
マイナウォレットではどのようにUserOperationを生成しているか
UserOperationの中身がちょっと分かったので、実際にどのように生成しているのか見ていきます。
マイナウォレットでは、サーバ内の以下部分で生成していました。
サーバに対して送金先・金額・modulus(マイナンバーの公開鍵)を含めたGETリクエストを送ると、"どのようなトランザクションを発行するか"や、上述のinitCodeの生成・取得が行われます。
また、214行目ではuserOpHashの生成もしています。getUserOpHashが実装されている別ライブラリのコードを見てみましょう。
この時点でSignatureはkeyのみでvalueは入っていませんが、packUserOpでSignature部分を取り除き、getUserOpHashではそれをkeccak256でハッシュ化、その後にEntryPointのアドレス・chainIdも結合させて再度ハッシュ化という流れになっています。
userOpHashの取得が完了した後は、これに署名をしてもらってSignatureを生成しなければなりません。
署名はクライアント側で行うため、最初に送ったGETリクエストのレスポンスとして、以下二つを返します。
- userOpHash
- 生のuserOperation
クライアント側でuserOpHashに署名をしてもらった後、それを生のuserOperationに追加してもらうことで完全なUserOperationが出来上がるってわけですね!
その後、完全なUserOperationをクライアント側から送り返してもらったら、あとはそれをBundlerへ送信するだけです!
マイナウォレットでは、UserOperationを含めたPOSTリクエストをサーバ側に送ると、sendUserOpToBundlerという関数によってBundlerへ送信します。
一応sendUserOpToBundlerの実装箇所も載せておきます。
これでUserOperationをBundlerに渡すまでの流れは確認できました。
EntryPointがUserOperationを検証するまで
続いて、EntryPointがUserOperationを検証するまでを見ていきます。
EntryPoint
まずは、BundlerがいくつかのUserOperationを集め、それをEntryPointのhandleOps関数の引数として設定してトランザクションを発行します。
126行目の_validatePrepaymentの戻り値の中にvalidationDataというものがあり怪しそうなので、中を見ていきます。
579行目でgetUserOpHash関数をコールしてuserOpHashを生成していました。
(もちろんSignature部分は除く処理がgetUserOpHashの中で行われています)
後で、このuserOpHashとSignatureを突き合わせて検証を行うため、UserOperationが改竄はできません。
その後、595行目でコールしている_validateAccountPrepaymentの中身は以下です。
少し検証からは逸れますが、417行目で_createSenderIfNeeded関数をコールしています。
もしウォレットコントラクトが未デプロイだった場合、UserOperation内のinitCodeを使って、ウォレットコントラクトをデプロイします。このためのinitCode!
その後、428行目でウォレットコントラクトのvalidateUserOpをコールしています。
引数にはUserOperationと上で生成したuserOpHashが設定されていますね 。ウォレットコントラクト側の実装を見てみましょう。
ウォレットコントラクト
BaseAccount.sol
validateUserOpはBaseAccount.solに実装がされています。
独自のウォレットを作る場合はこのコントラクトを継承する形になると思います。
EntryPointからのコールかをチェックした後、_validateSignatureを呼んでいるので見てみます。
SimpleAccount.sol
こちらはSimpleAccount.solにありました。
この部分にオリジナルの検証処理を実装することで、様々な認証方法が実現できるってことですね!
以下サンプルでは、ECDSAの検証処理が実装されています。
マイナウォレットの_validateSignature
マイナウォレットでは、RSAの検証処理が実装されています。
開発者の方のツイートによるとRSAの検証はガス代が高いらしいですが、それを解決する方法も研究されているとのことです。
検証できた後はUserOperation内のcallDataの実行に移っていくわけですが、まだ調べきれていないため、いつか続きを書こうと思います。いつか。。
終わりに
なんとなく雰囲気だけ理解してた部分がしっかり理解できたので良かったです。
間違ってる部分があれば、ご指摘いただけると幸いです。
以下は大変参考にさせていただいた記事です。ありがとうございます。
Discussion