前回私が担当したエンジニアブログでは、私が用意した ERC-4337 でのトランザクションの実行環境を実際に構築し、ブラウザ上のインターフェイス経由でお手軽に体験するまでの手順を解説しました。今回は、実際にこのインターフェイスでトランザクションの送信ボタンを押した裏側で何が実行されているか、実際のソースコードに合わせて解説していきます。
なお、ソースコードは全て GitHub 上に公開しています。また、今回解説する処理の大半は src/app/page.tsx
に記述しています。
目次
アカウントコントラクトのアドレスを取得するまで
どのような処理を実行するにも、まずは「私」のアカウントのアドレスが何であるかを求める必要があります。この「私のアカウントのアドレス」を求めるには、大きく分けて二つの方式があります。
- 規格に対応する Account コントラクトを自分で事前にデプロイし、デプロイ先のアドレスを得る
- AccountFactory から initCode を得て、それを
EntryPoint.getSenderAddress()
に渡すことで counter-factual address を得る
2 番目の方式は EIP-1014 で定められた CREATE2
OPCODE を用いることで、デプロイ前にコントラクトのアドレスを決定論的に導出できることを利用します。具体的にデモのソースコードでは、インターフェースから変更可能な nonce を設定した上で、トランザクション(ユーザーオペレーション)を送信する前に、L85-L93 で SDK 経由で上述の処理を行うことで Account コントラクトのアドレスを導出しています。
const getCounterFactualAddress = async () => { const addr = await accountAPI.getCounterFactualAddress(); setAccountAPIData((prev) => { return { ...prev, counterFactualAddress: addr, }; }); };
「送信ボタン」の裏側で実行されるプログラム
UserOperation の生成
構築した環境のインターフェースでトランザクションの実行ボタンを押した後、始めに起こることはユーザ・オペレーション User Operations(以下、userOP)の生成・署名と Bundler への送信です。以前のブログ記事で、userOP は以下の図に示すような流れで処理されると説明しました。
つまり、ERC4337 に対応した実装では、まずは各エンドノード上で UserOP を生成し、それを Bundler と呼ばれる特殊なクライアントに送信する必要があるわけです。デモのソースコードでは、L280-L411 がこれにあたります。まず、UserOpretiton の構造のおさらいをしておきましょう。UserOperation に含まれるフィールドは、以下に示すようなものでした。
struct UserOperation { address sender; uint256 nonce; bytes initCode; bytes callData; uint256 callGasLimit; uint256 verificationGasLimit; uint256 preVerificationGas; uint256 maxFeePerGas; uint256 maxPriorityFeePerGas; bytes paymasterAndData; bytes signature; }
通常のトランザクションの構造にとても似ています。このうち、sender はすでに導出している自分の(操作したい)Account コントラクトのアドレスが入ります。nonce
や initCode
、callGasLimit
、verificationGasLimit
、preVerificationGas
、maxFeePerGas
、maxPriorityFeePerGas
に関しても Bundler を通じて EntryPoint や Account Factory に問い合わせることで然るべき値を得ることができます。paymasterAndData
には使用する Paymaster のアドレスや Paymaster に渡す引数などが格納されます。問題は callData
と signature
です。
callData の生成
callData
には通常のトランザクションと同様にコントラクトを呼び出す指示のバイト列が入ります。と、言いつつもそんなに難しくありません。ethers.js にはスマートコントラクトの ABI から callData
を生成してくれるユーティリティ関数が存在します。また、typechain を使っていると複雑な型周りもよしなにしてくれます。デモのソースコードでは、L294-L357 の switch 文内で送信するトランザクションの種別に応じて必要な callData
を生成しています。具体的には、各 case 文内のContract.connect(address, provider).interface.encodeFunctionData(methodID, args)
に当たる部分が callData
の生成を行っています。
let callData: string; let txTarget: string; switch (txType) { case "ETH": { callData = "0x"; txTarget = txTo; break; } case "ERC20": { if (!erc20ContractAddress) { return; } txTarget = erc20ContractAddress; callData = SampleToken__factory.connect( erc20ContractAddress, provider ).interface.encodeFunctionData("transfer", [ txTo, ethers.utils.parseEther(txValue.toString()), ]); break; } case "ERC721": { if (!nftContractAddress || !counterFactualAddress) { return; } txTarget = nftContractAddress; callData = SampleNFT__factory.connect( nftContractAddress, provider ).interface.encodeFunctionData( "safeTransferFrom(address,address,uint256)", [counterFactualAddress, txTo, txValue] ); break; } case "PaymasterDeposit": { if (!paymasterAddress) { return; } txTarget = paymasterAddress; callData = SamplePaymaster__factory.connect( paymasterAddress, provider ).interface.encodeFunctionData("depositTo", [ txTo, ethers.utils.parseEther(txPaymasterOverhead.toString()), ]); break; } default: { callData = "0x"; txTarget = txTo; break; } }
UserOperation への署名
続いて、こうして得られた callData
を含めた(signature
が存在しない) userOp に対して任意の署名アルゴリズムを用いて署名を生成します。今回は 0x7777… から始まる既知の秘密鍵を用いて secp265k1 の ECDSA での署名を利用していますが、この解説シリーズで何度も述べている通り、ここでの署名アルゴリズムはアカウントコントラクトの実装依存であって、secp256k1 を必ずしも用いる必要性はありません。デモのソースコートでは L369-L392 にあたります。
let userOp: UserOperationStruct; // ... 中略... if (usePaymaster && paymasterAddress) { const preVerificationGas = await accountAPI.getPreVerificationGas( await resolveProperties({ ...unsigOp, paymasterAndData: paymasterAddress, preVerificationGas: 21000, // dummy value, just for calldata cost signature: hexlify(Buffer.alloc(65, 1)), // dummy signature }) ); userOp = await accountAPI.signUserOp({ ...unsigOp, preVerificationGas, paymasterAndData: usePaymaster ? paymasterAddress : "0x", }); } else if (!usePaymaster) { userOp = await accountAPI.signUserOp(unsigOp); } else { return; }
こうして得た署名を signature
フィールドに与えた userOp を Bundler に送信することで、userOp を mempool に追加することができます。
Bundler 内部での処理
ここから先は Bundler 内部で処理が走り、エンドユーザのクライアントはただその実行結果を待つだけになります。ですが、せっかくですので Bundler 内でどのような処理が行われるかを少しだけ詳しく解説します。
エンドユーザのクライアントから userOp を受け取った Bundler は、まずその userOp が正常に実行できるかのシミュレーションを行います。このシミュレーションで失敗した場合はそのまま mempool から該当 userOp を破棄します。シミュレーションでは、以下のようなことなどが確認されます。
- 署名が正しいこと
- callData に基づいたコントラクトの実行が正常に完了すること
- 送り主(userOp.sender)が十分量のガス代を保有していること
署名が正しいか否かは、Account コントラクトの Account.validateUserOp()
を呼び出すことで確認されます。つまり、ここが AA の肝の一つである「暗号アルゴリズムを柔軟に選択できる」ことを実現している部分なのです。今回のデモではリファレンス実装内のサンプルコントラクトをそのまま利用しており、以下のような Account.validateUserOp()
の実装になっています。当然、この Account.validateUserOp()
を変更すれば、任意の検証ロジックを実装できます。
// --- account-abstraction/contracts/core/BaseAccount.sol function validateUserOp( PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds ) external virtual override returns (uint256 validationData) { _requireFromEntryPoint(); validationData = _validateSignature(userOp, userOpHash); _validateNonce(userOp.nonce); _payPrefund(missingAccountFunds); } // --- account-abstraction/contracts/samples/SimpleAccount.sol function _validateSignature( PackedUserOperation calldata userOp, bytes32 userOpHash ) internal override virtual returns (uint256 validationData) { bytes32 hash = MessageHashUtils.toEthSignedMessageHash(userOpHash); if (owner != ECDSA.recover(hash, userOp.signature)) return SIG_VALIDATION_FAILED; return 0; }
Source: https://github.com/eth-infinitism/account-abstraction
シミュレーションに成功し、正常に操作が完了できることを確認できた場合、Bundler は EntryPoint.handleOps()
の引数に userOp を指定してトランザクションを発行します。
EntryPoint は受け取った userOp を再度検証して(verification loop)、成功した場合は実際に callData に基づいてコントラクトの呼び出しを実行します。これによって EVM 上の状態が実際に変化することになります。逆に、Bundler のシミュレーションの段階では(当然)状態は変化せず、gas 代がかかることもありません。このことを理解すると、userOp.callData
でのコントラクト呼び出しの際にコントラクトがアクセスできる資源に制約を設ける必要性も理解できると思います。Bundler のシミュレーション時と EntryPoint で実際に実行される時とで状態が変わっている可能性のある資源には、アクセスできないようにする必要があるわけです。このような資源には現在のブロック高や gasPrice
、sender アドレス以外の残高などが含まれます。
まとめ
今回までの 3 回にわたって、Account Abstraction について解説しました。ERC-4337 はまだドラフトの段階であり、正式に標準として認められているわけではないため、今後も変更が加わる可能性がある点はご留意いただく必要があります。一方で、個人的な意見ではありますが、リファレンス実装の SDK もよくできている他、サードパーティの SDK もぽつぽつと公開されてきているため、概念さえつかめればそこまで実装の難易度も高くないかと思います。一連の記事が皆さんのご理解の一助となれば幸いです。