はじめに
Account Abstraction(アカウント抽象化、以下AAと記載します)は、ブロックチェーンユーザの利便性を向上するための技術です。
従来のスマートコントラクトの呼出しでは、ユーザ自身がトランザクションの送信者となり、ユーザ自身がガス代を支払う必要がありました。
AAを適用する場合、ユーザが送信するのはuserOperationと呼ばれる構造体で、自身がトランザクション送信する必要はなくなります。
また、AAのオプション機能としてガス代支払いの代行機構を利用することができ、ユーザのオンボーディングを促進するようなサービスモデルを実現できます。たとえば、暗号資産の知識がないユーザーはNFTを購入するハードルが高いと思われます。ユーザーが暗号資産を持たずとも、NFTの販売者が代理人となりガス代を支払うようなモデルも考えられます。
この記事はAAの構築および、それを利用するためのクライアントプログラムの書き方のポイントについて説明します。
Account Abstraction 概観
AAについてはEthereum Foundationが提供する下記のサイトに詳しくまとまっています。AlchemyやStackupといった企業によって提供されるSDKやライブラリなどのリソース群についても当該サイトに一覧化されていますので、用途に合ったリソースを探すことができます。
https://www.erc4337.io/
まず、AAの概念においてはSmart contract account(スマートコントラクトアカウント、以下SCAと記載します)という概念が重要な役割を持ちます。
EOAはブロックチェーンの外部でウォレットなどにより管理するアドレスですが、AAの概念におけるSCAはブロックチェーン内部で管理され、ウォレットとして使用できるスマートコントラクトです。
従来のスマートコントラクトの呼出しは以下のようにユーザが実行したい処理の実行者はEOAで、トランザクション送信者もEOAです。ガス代の支払者もEOAである必要があります。
対してAAを適用する場合、処理の実行者はSCAです。AAを適用した場合の呼出しはユーザが実行したい処理の実行者はSCAで、トランザクション送信者もBundlerです。ガス代の支払者は任意のアドレスを指定可能です。以下のようなフローで、ユーザが実行したい処理をuserOperationという構造体に記載してトランザクションに含めたうえでAAの機構で処理します。ユーザがEOAを持つ場合、ユーザは自身に紐づいたSCAによる処理の実行の正当性を示す(=userOperationに署名する)だけでよく、ユーザのEOAがトランザクション送信者となることなくスマートコントラクト上の関数を実行することができます。
前述の通り処理の実行者はSCAですが、userOperationを含んだトランザクションの送信者はBundlerが、userOperatiionの実行にかかるガス代はSCAまたは予めデポジットをされたEntryPointが担います。
以上の内容を表にまとめます。
従来のコントラクト呼び出し | AAでのコントラクト呼出し | |
---|---|---|
トランザクション署名者 | EOA(ユーザー) | Bundler |
トランザクション送信者 | EOA(ユーザー) | Bundler |
ガス代支払い者 | EOA(ユーザー) | SCA (paymaster設定により任意のアドレスに変更可能) |
コントラクト呼出しの主体 | EOA(ユーザー) | SCA |
userOperation署名者 | - | SCAと関連するユーザ(ECDSA署名の場合EOA) |
userOperationによるコントラクト呼出しの主体 | - | SCA |
また、以上をアクターの観点でまとめます。
アクター | 概要 |
---|---|
EOA(ユーザー) | ブロックチェーンの外部でウォレットなどにより管理するアドレス。従来のスマートコントラクト呼出しにおける実行者 |
SCA | ブロックチェーン内部で管理されるスマートコントラクト。AAの機構においては処理の実行者となる |
Bundler | AAの機構の一部。ブロックチェーンの外部に位置し、userOperationを受け付け、userOperationを内包するトランザクションを作成し、EntryPointに送信する |
EntryPoint | AAの機構の一部。スマートコントラクト。userOperationを含んだトランザクションを受け付けて処理する |
使用する主なミドルウェア
今回実施する環境で使用する主なミドルウェアは以下です。
ローカルAA機構
- nodejs version v20.11.1
- @account-abstraction/contracts ^0.7.0
- @openzeppelin/contracts ^5.0.2
- @openzeppelin/contracts-upgradeable ^5.0.2
- @nomicfoundation/hardhat-toolbox ^5.0.0
- hardhat ^2.22.4
クライアントプログラム
- jbr-11(OpenJDK11ベースのintellliJ用SDK)
- IntelliJ IDEA 2022.1.3 (Community Edition)
- org.web3j:core:4.12.0
ローカルAA構築
概観の項で述べた通り、AAにはBundlerというuserOperationをトランザクションに含めるアクターが存在しますが、今回はここを省き、AAのコア機能であるuserOperationを実行するスマートコントラクト群を構築しました。
また、AAのオプションであるPaymasterというアクターも構築対象に含めました。Paymasterはガス代の肩代わりのルールを実装するスマートコントラクトです。EntryPointがuserOperationを含んだトランザクションを処理する際に、Paymasterに記載されたルールに照らして正当なガス代支払いが可能かを検証します。
概念図は以下です。今回はuserOperationを送信するユーザはEOAを持つものとしました。予めEOAに関連づいたSCAを作成済みであり、そのSCAが実行者であるuserOperationを含んだトランザクションをEntryPointに送信します(①)。EntryPointは受け取ったuserOperationに対し様々な検証をします。例えばuserOperationは送信者の正当性を証明する署名がされているか、ガス代支払いのルールに沿っているか等です(②)。それらの検証が通ったのちに、userOperationに記載された処理が実行されます(③)。
なお今回の構築ではAAの構成要素となるスマートコントラクトは以下から採取し、適宜修正して使用しました。概観の項に記載したEthereum Foundationサイトでも紹介されているリポジトリです(以下inifinitismと記載します)。
https://github.com/eth-infinitism/account-abstraction
- EntryPoint
- Paymaster
- SimpleAccountFactory
- SimpleAccount
実行したい処理として、任意の内容のスマートコントラクトを作成します。
今回はシンプルに、スマートコントラクトの変数を加算する関数を実装します。処理が正常に行われたか確認するための参照用の関数も実装しておきます。
// 動作確認用属性
uint256 public myNum = 0;
function addMyNum(uint256 addNum) public returns (uint256) {
myNum = myNum + addNum;
return myNum;
}
function getMyNum() public view returns (uint256) {
return myNum;
}
ローカルAA構築手順
今回実施するAA構築手順は以下の通りです。ローカル環境で稼働したhardhatのプロセスに対して各操作を実施します。
①EntryPoint、Paymaster、SimpleAccountFactoryをローカル環境にデプロイする
②デプロイしたFactoryコントラクトの関数を呼び出すことによりSCAコントラクトをデプロイする
③後のUserOperation実行に必要な資金をEntryPointにdepositする
④支払いルールをPaymasterに設定する
⑤任意の処理を記載したスマートコントラクトをデプロイする
ローカルAA構築時のポイント・SCAのアドレス決定とデプロイは切り離し可能
userOperationでは、処理を実行する主体となるSCAのアドレスを記載する必要があります。今回は事前にFactoryを使ってSCAをデプロイしましたが、SCAをデプロイしていない状態でuserOperationを実行し、userOperationの処理と同時にSCAのデプロイを実行する、という処理フローも可能です。
デプロイしていない(=ブロックチェーン上に存在していない)スマートコントラクト(SCA)のアドレスを指定できる仕組みは理解しづらいのではないかと思います(私は悩みました)。
なぜそのようなことができるかというと、SCAアドレスの算出ロジックには冪等性があること、SCAアドレスの決定にはCREATE2という、予測可能なスマートコントラクトアドレスを算出するopcodeが使われていることが理由でした。
以下がinifinitismから抜粋した、SCAのアドレスを算出する関数です。
/**
* calculate the counterfactual address of this account as it would be returned by createAccount()
*/
function getAddress(address owner,uint256 salt) public view returns (address) {
return Create2.computeAddress(bytes32(salt), keccak256(abi.encodePacked(
type(ERC1967Proxy).creationCode,
abi.encode(
address(accountImplementation),
abi.encodeCall(SimpleAccount.initialize, (owner))
)
)));
}
SCAは、予めデプロイされたFactoryというコントラクトのcreateAccount関数の実行によりデプロイされます。SCAはこのcreateAccount関数の実行時にデプロイされるのですが、内部的にこのgetAddress関数を呼び出します。このgetAddress関数は、引数で指定されるSCAと関連するEOAのアドレス(引数owner)およびソルト(引数salt)が同一であれば、何度呼び出しても同じ結果を返します。
createAccount関数の実行時には、内部的にこのgetAddress関数がインターナル関数として呼び出されます。引数として指定する二つの値を同一にしさえすれば、Create2.computeAddressが必ず一つのアドレスを予測してくれるということのようです。
CREATE2の理解についてはこのサイトが参考になりました。
クライアントプログラムの作成
クライアントプログラムでは、まずはuserOperationを作成します。大まかに言うと、だれが何を実行したいかの情報を構造をuserOperationに書き込んで署名情報を付加します。そのuserOperationを含んだトランザクションを作成して送信するのですが、そのトランザクションの実体は、EntryPointコントラクトのhandleOps関数の呼出し処理です。
ERC4337で定義されたuserOperationの属性は以下です
(https://www.erc4337.io/docs/understanding-ERC-4337/user-operation より引用)。
各項目の概要を説明します。
①sender:処理の実行者となるSCAのアドレスです。
②nonce:トランザクションにおけるnonceと同じと理解すればよいです。
③initCode:senderのアドレスがブロックチェーン上に存在しないとき、SCAをデプロイするための処理です。SCAがデプロイ済みであれば空欄にする必要があります(存在するのにinitCodeを入れるとエラーになります)。
④callData:このuserOperationで実行したい処理です。この実体はSCA(今回はSimpleAccount.sol)のexecute関数の呼出しです。execute関数の引数に、実行したい処理(今回はaddMyNum関数)を設定します。
⑤gas関連値:userOperationの実行で消費される手数料に関する設定です。
⑥paymasterAndData:ガス代支払いの代行機構の情報です。ここを記載しない場合、ガス代はsenderが支払うことになり、SCA自身に資金をデポジットしておく必要があります。
⑦signature:このuserOperationの正当性(誰か知らない人がsenderを騙って処理を実行するのを防ぐ)を表す署名です。今回はSCAに紐づくEOAの秘密鍵による署名です。signatureを除く全項目をハッシュ化したデータに対する署名ですので、userOperationの各項目の改ざんはできません。
userOperationの作成に際しては、各項目を単純に構造化するわけではなく、いくつか難しい点がありました。それらのポイントに絞って説明します。
クライアントプログラム構築時のポイント1・ECDSA署名作成メソッドの選定
JavaでEntryPointのhandleOps関数を呼び出す際に重要なのは、web3jのライブラリに含まれる複数の署名メソッドのうちどれを選ぶかです。
これまでEOAが送信するトランザクションの署名には以下を使用していたのですが、
パッケージ名:org.web3j.crypto
クラス名:Sign
メソッド名:signMessage
今回userOperationの署名作成には以下を使用する必要がありました。
パッケージ名:org.web3j.crypto
クラス名:Sign
メソッド名:signPrefixedMessage
なおEoAによるトランザクション送信時には必ずECDSA署名を使う必要がありますが、userOperationの署名においては他の署名方法も使用できます。スマートコントラクト側で署名を検証する処理と同じ署名方法である必要がありますが、inifinitismではECDSA署名のほかにBLS署名を検証するサンプルも提供されていました。
このサンプルを使用する場合は、上で述べたJava側の署名作成で使用するメソッドもまたBLS署名用に変わってきます。
クライアントプログラム構築時のポイント2・userOperationの複数項目の統合と分解
今回クライアントプログラムではuserOperationを作成しますが、userOperationを処理するEntryPointのhandleOps関数の引数はPackedUserOperationという構造体です。Packedという名称がポイントになってきます。
以下プログラムは https://github.com/eth-infinitism/account-abstraction より引用
クライアントプログラムの章の冒頭に記載したuserOperationの各属性とは、項目が異なる(同じ項目もあるが、userOperationの属性の方が属性数が多い)ことが分かるかと思います。
userOperationはトランザクションに含まれるので、ガス代を抑えるために引数の数を少なくして処理を単純にする工夫がされていると考えられます。工夫の仕組みは、複数の項目をルールにそって統合していることです。
例えば、EntryPointの内部で行われる以下の処理を見てみると、PackedUserOperationのaccountGasLimitsという属性を、callGasLimitとverificationGasLimitに分割していることが分かります。
(mUserOp.verificationGasLimit, mUserOp.callGasLimit) = UserOperationLib.unpackUints(userOp.accountGasLimits);
この分割がどのように行われているかを見ていくと、32バイトのデータを受け取って、16バイトずつに分割していることが分かります。
function unpackUints(
bytes32 packed
) internal pure returns (uint256 high128, uint256 low128) {
return (uint128(bytes16(packed)), uint128(uint256(packed)));
}
userOperationの作り手であるクライアントプログラムでは、この処理を念頭に置き、callGasLimitとverificationGasLimitに設定したい値を16バイトのデータに詰め、接合して(接合した時点で32バイトとなる)accountGasLimitsに設定します。
このように、userOperationを処理するスマートコントラクト側で、handleOps関数呼出し後に各引数が内部的にどのような分割処理がされていくのか把握することが、クライアントプログラムを実装する上でのポイントとなります。
クライアントプログラムからuserOperationを実行する
構築したローカルAAに対して、ローカルAA構築の項で述べた変数を加算する処理を、userOperationを使って実行してみました。
AA機構のデプロイ
ローカルAA構築の項で説明した各コントラクトをデプロイします。またSCAが処理を実行する際の資金のデポジットとpaymaster設定も行います。
動作確認に必要な主要なアクターのアドレスは以下です。
EntryPoint deployed to: 0xaD8a89Fa8bF484c6f07edc509C49B88388D6eF48
Paymaster deployed to: 0xB0ebe2372639944705c60Df83bc99f8A2349E499
SCA address: 0x1acE18433A5418BDe4a6573D751eA6ADe061A4B5
実行前のローカル変数myNum
実行前はゼロです。成功すればここが変わるはずです。
$ npx hardhat run --network localhost scripts/getMyNum.js
myNum: 0
実行前の各アクターの資金の残高
今回デプロイしたSCAにデポジットされた資金はゼロです。
0x1acE18433A5418BDe4a6573D751eA6ADe061A4B5(sca): 0
対して、ガス代代行支払い機構には5000000000000000000Gwei、すなわち5Ethデポジットしました。
Deposit Amount: 5000000000000000000
クライアントプログラムからuserOperation送信
クライアントプログラムからローカル変数myNumに14加算するUOをEntryPointに送信します。
javaのクライアントプログラムでは、EntryPointのコントラクトアドレスやSCAアドレスなど、userOperationの送信に必要なアドレス情報を予め設定しておきます。
handleOps関数の呼出し結果にエラーが無いことを確認します。
handleOpsRequest: {"transaction":{"type":"LEGACY","nonce":8,"gasPrice":360291869,"gasLimit":2097152,"to":"0x5C9bDC743146A5F58A62f8FC84D1452b28d9886B","value":0,"data":"765e827f0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c1c4d577dc13e6e462ba593bd14cfbb90d17f6df0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000065b20a4667c210bfa7e974a6265f6beaa2364bde00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000014000000000000000000000000001312d00000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000004c4b400000000000000000000000003b9aca00000000000000000000000002540be400000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c4b61d27f6000000000000000000000000a2c6cd91f73c4be09e4a092e519eaf07e40b6bf500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000002411579cef000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000034ac9be3b8d3db16bda849c556a7bde03f782229f600000000000000000000000001312d00000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000041d65729471a5bd968b46dd7f0d149d3a54f2dc8f16cd6dc9792fefb1cabd05543771fa5f307f0b12b04cb173204dae6673a61bdf505bb3fcc7b8354ceee81aebf1c00000000000000000000000000000000000000000000000000000000000000"},"value":0,"type":"LEGACY","data":"765e827f0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c1c4d577dc13e6e462ba593bd14cfbb90d17f6df0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000065b20a4667c210bfa7e974a6265f6beaa2364bde00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000014000000000000000000000000001312d00000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000004c4b400000000000000000000000003b9aca00000000000000000000000002540be400000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c4b61d27f6000000000000000000000000a2c6cd91f73c4be09e4a092e519eaf07e40b6bf500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000002411579cef000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000034ac9be3b8d3db16bda849c556a7bde03f782229f600000000000000000000000001312d00000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000041d65729471a5bd968b46dd7f0d149d3a54f2dc8f16cd6dc9792fefb1cabd05543771fa5f307f0b12b04cb173204dae6673a61bdf505bb3fcc7b8354ceee81aebf1c00000000000000000000000000000000000000000000000000000000000000","nonce":8,"gasPrice":360291869,"to":"0x5C9bDC743146A5F58A62f8FC84D1452b28d9886B","gasLimit":2097152}
handleOpsResponse: {"id":1,"jsonrpc":"2.0","result":"0x729884b4d3912495616b096fb3ab9713214a0feacc58f879eb8e4fbaabbd4a40","error":null,"rawResponse":null,"transactionHash":"0x729884b4d3912495616b096fb3ab9713214a0feacc58f879eb8e4fbaabbd4a40"}
TransactionHash: 0x729884b4d3912495616b096fb3ab9713214a0feacc58f879eb8e4fbaabbd4a40
実行後のローカル変数myNum
ゼロから増加していることから、userOperationが正常に実行されたことが分かります。
$ npx hardhat run --network localhost scripts/getMyNum.js
myNum: 14
実行後の各アクターの資金の残高
実行前の各アクターの資金の残高 で確認したとおり、SCAは元から資金がゼロだったので、今回userOperationの実行に際してガスを支払ったのはSCAではありません。
対して、ガス代代行支払い機構の資金が約4.9Ethと減少しています。
Deposit Amount: 4993478304415104572
以上より、userOperationの処理が正常に行われ、かかったgas代はsenderの資産でなくpaymasterの資産から使用されたことがわかります。
おわりに
以上のようにローカルで構築したAA機構をJavaのクライアントプログラムから呼出して、スマートコントラクトの任意の処理を実行することができました。
今回はAAの機構からクライアントプログラムまで、全てを自前で実装しましたが、スマートコントラクト側で行われる検証や値の分割のプログラムを詳細に把握する必要がある点で、時間がかかる作業です。様々な企業がAA利用のためのSDKやサービスを提供していますので、そういった出来合いのリソースをうまく使うことも、AAを取り入れたシステム開発の際には検討する必要がありそうです。