🔬

ERC-4337 AccountAbstraction stackup tutorial で動きをより理解する

2023/05/02に公開

stackupとは?

ERC-4337 (AccountAbstraction) で必要になる Bundler や mempool paymaster などのサーバーリソースを IaaS, SaaSのような形でサービスしているベンチャー企業(2021年8月創業)。 ERC-4337にかんするDocumentやサンプルソースも豊富で今後ERC-4337の広がりとともに成長が予想されます。

ERC-4337 Stackup tutorial

下記のAccountAbstraction全体の流れのなかで、自分たちで作ることになる部分はどこなのかをより理解するためにStackupのサンプルを動かしていきます。

ERC-4337 overall

基本的には下記のtutorialにそってすすめるだけです。 とても簡単で動かしながら仕組みを理解できるのでおすすめです。

https://docs.stackup.sh/docs/getting-started

こちらにそってサンプルのコードをgit cloneして実行していきます。

stackupの bundler を使うためのアカウント作成、APIKEY取得

サンプルを動かすときに必要になるbundler のRPC API を利用できるようにするために
Stckupにアカウントを作成します。下記にアクセスしてSignIn(SignUpは不要という割り切り)して
メールアドレスを打ち込むとそのアドレスにむけて ログインリンクがおくられてきます。

https://app.stackup.sh/

そこでbundlerのnodeをつくり、API Keyをコピーし、configを書き換えます

sample で smartAccountを作成し、matic faucet でToken補充

(base) erc-4337-examples % yarn run simpleAccount address
yarn run v1.22.18
$ ts-node scripts/simpleAccount/index.ts address
SimpleAccount address: 0x6dB91d34fdB632314d70F885c3DA456E084a52E9

下記で 上記で生成した SmartAccountのAddressにMatic 補充
https://faucet.polygon.technology/

下記で初めてのtransfer(送金) 実行

上記で作成したSmartAccountのAddressから、
下記のコマンドで任意のWalletに送金してみます。

簡単すぎる!! メタマスクも 署名ボタンも出てきません。

(base) erc-4337-examples % yarn run simpleAccount transfer --to 0x3e7311e0Fc89fA633433076be268ae007A1b827a --amount 0.01
yarn run v1.22.18
$ ts-node scripts/simpleAccount/index.ts transfer --to 0x3e7311e0Fc89fA633433076be268ae007A1b827a --amount 0.01
Signed UserOperation: {
  sender: '0x6dB91d34fdB632314d70F885c3DA456E084a52E9',
  nonce: '0x0',
  initCode: '0x9406cc6185a346906296840746125a0e449764545fbfb9cf000000000000000000000000c58daeda36f87b1d9b073fbe99dd19bf52c445100000000000000000000000000000000000000000000000000000000000000000',
  callData: '0xb61d27f60000000000000000000000003e7311e0fc89fa633433076be268ae007a1b827a000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000',
  callGasLimit: '0x814c',
  verificationGasLimit: '0x583f4',
  preVerificationGas: '0xafa8',
  maxFeePerGas: '0x6507a5e0',
ƒ  maxPriorityFeePerGas: '0x6507a5c0',
  paymasterAndData: '0x',
  signature: '0xd06438ab728f1c89043a644570c0a2e12811f35b37461ac6fd0cbb4655fab2cf6b92e0e903aa26ab5dfc37a863915b75175fed469daa4167a4c1b7c25b23ce681b'
}
UserOpHash: 0xd8e8f5369ffd2956493af84d2e5e8df0a5a4e7d42c8f5e56520b8222cb009d81
Waiting for transaction...
Transaction hash: 0xd14aa6dd5272b8dbce18f4bb51db8b3e1cbaf592ce87df1373acd2df932c07cb
✨  Done in 20.08s.
(base) erc-4337-examples % 

動作としては簡単に確認できたので、中身をみていきます。

SimpleAccount command (Type Script)

https://github.com/stackup-wallet/erc-4337-examples/

まずsimpleAccountのコマンドの実体は下記から type scriptでかかれたコードですね

pakcage.json
 "simpleAccount": "ts-node scripts/simpleAccount/index.ts"

中をみていきます。

scripts/simpleAccount/index.ts
program
  .command("address")
  .description("Generate a counterfactual address.")
  .action(address);

下記でimport している userop 自体も stackupが公開しているライブラリです。
ERC-4337のContractAccountで定義されているInterfaceにそって呼び出すような仕組みになっています。

scripts/simpleAccount/address.ts
import { Presets } from "userop";
export default async function main() {
  const simpleAccount = await Presets.Builder.SimpleAccount.init(
    config.signingKey,
    config.rpcUrl,
    config.entryPoint,
    config.simpleAccountFactory
  );
  const address = simpleAccount.getSender();

  console.log(`SimpleAccount address: ${address}`);
}

useropライブラリの中をさぐっていくと下記のような処理になっています。
SimpleAccountFactoryというContractを作り出すContractが 個々の署名用秘密鍵に対応したWalletをつくりだしています。

https://github.com/stackup-wallet/userop.js/blob/main/src/preset/builder/simpleAccount.ts

stackup の UserOp library の説明を要約

staqckupが用意してくれた便利関数を理解していきます。

https://docs.stackup.sh/docs/useropjs-presets
ここの内容を要約します。

UserOp.jsは、Ethereumのアカウント抽象化(Account Abstraction)を扱うためのライブラリです。このライブラリには、いくつかのプリセット(Preset)が含まれており、特定のユースケースをすぐに始めることができます。

プリセットには、既知のスマートコントラクトアカウント実装に対するビルダー機能が用意されています。これにより、同じビルダーやミドルウェア関数を何度も作成する必要がなくなります。UserOp.jsに用意されたプリセットは、さまざまなビルダーインスタンスで使いまわすことができる便利なミドルウェア関数も提供しています。

これらのプリセットは、開発者が簡単にアカウント抽象化に関連するタスクを実行できるようにするためのツールであり、コードの再利用や機能の迅速な実装を可能にします。

以下は、SimpleAccountプリセットを使用してUserOperationを実行するサンプルコードです。

import { Client, Presets } from "userop";

const simpleAccount = await Presets.Builder.SimpleAccount.init(
  config.signingKey,
  config.rpcUrl,
  config.entryPoint,
  config.simpleAccountFactory
);
const client = await Client.init(config.rpcUrl, config.entryPoint);

const res = await client.sendUserOperation(
  simpleAccount.execute(target, value, "0x"),
  { onBuild: (op) => console.log("Signed UserOperation:", op) }
);
console.log(`UserOpHash: ${res.userOpHash}`);

console.log("Waiting for transaction...");
const ev = await res.wait();
console.log(`Transaction hash: ${ev?.transactionHash ?? null}`);

このコードでは、まずSimpleAccountプリセットを初期化してから、Clientを初期化しています。次に、UserOperationを送信し、UserOpHashとトランザクションハッシュをログに出力しています。

このサンプルコードでは、ミドルウェア関数を使ってUserOperationのガス料金推定、ガス価格取得、ペイマスターの検証、およびEOA署名を行います。以下は、各ミドルウェア関数の使用例です。

  1. estimateUserOperationGasミドルウェア関数を適用:
import { Presets } from "userop";

// provider is an ethers.js JSON-RPC provider.
builder = builder.useMiddleware(Presets.Middleware.estimateUserOperationGas(provider));
  1. getGasPriceミドルウェア関数を適用:
import { Presets } from "userop";

// provider is an ethers.js JSON-RPC provider.
builder = builder.useMiddleware(Presets.Middleware.getGasPrice(provider));
  1. verifyingPaymasterミドルウェア関数を適用:
import { Presets } from "userop";

builder = builder.useMiddleware(
  Presets.Middleware.verifyingPaymaster(paymasterRpc, paymasterCtx)
);
  1. EOASignatureミドルウェア関数を適用:
import { ethers } from "ethers";
import { Presets } from "userop";

// signer is an ethers.js Wallet instance.
const signer = new ethers.Wallet(signingKey);
builder = builder.useMiddleware(Presets.Middleware.EOASignature(signer));

これらのミドルウェア関数を使うことで、UserOperationの送信に必要な各種設定や署名を容易に実行できます。これらのプリセットを組み合わせることで、さまざまなシナリオやコントラクトアカウントの実装に対応し、効率的な開発が可能になります。

transfer の署名はどこで?

userOp.jsの概要は理解できたのでいよいよ本題、署名はどこで行われているのかについてです。
下記がtransfer 実行時のtypescript コードです。

scripts/simpleAccount/transfer.ts
export default async function main(t: string, amt: string, opts: CLIOpts) {
  const paymaster = opts.withPM
    ? Presets.Middleware.verifyingPaymaster(
        config.paymaster.rpcUrl,
        config.paymaster.context
      )
    : undefined;
  const simpleAccount = await Presets.Builder.SimpleAccount.init(
    config.signingKey,
    config.rpcUrl,
    config.entryPoint,
    config.simpleAccountFactory,
    paymaster
  );
  const client = await Client.init(config.rpcUrl, config.entryPoint);

  const target = ethers.utils.getAddress(t);
  const value = ethers.utils.parseEther(amt);
  const res = await client.sendUserOperation(
    simpleAccount.execute(target, value, "0x"),
    {
      dryRun: opts.dryRun,
      onBuild: (op) => console.log("Signed UserOperation:", op),
    }
  );
  console.log(`UserOpHash: ${res.userOpHash}`);

  console.log("Waiting for transaction...");
  const ev = await res.wait();
  console.log(`Transaction hash: ${ev?.transactionHash ?? null}`);

UserOpのライブラリで、 entryPointに userOperationを送信するときに使用するのがclient です。
下記の部分でまさにUserOperationをおくっており、simpleAccountで署名をしているようです。

const res = await client.sendUserOperation(
    simpleAccount.execute(target, value, "0x"),

libraryの処理をおってみると下記で実装されていました。
https://github.com/stackup-wallet/userop.js/blob/main/src/preset/builder/simpleAccount.ts

下記の部分でsignerを設定し、

userop/src/preset/builder/simpleAccount.ts
   const base = instance
      .useDefaults({
        sender: instance.proxy.address,
        signature: await instance.signer.signMessage(
          ethers.utils.arrayify(ethers.utils.keccak256("0xdead"))
        ),
      })
      .useMiddleware(instance.resolveAccount)
      .useMiddleware(getGasPrice(instance.provider));

    const withPM = paymasterMiddleware
      ? base.useMiddleware(paymasterMiddleware)
      : base.useMiddleware(estimateUserOperationGas(instance.provider));

    return withPM.useMiddleware(EOASignature(instance.signer));

そして、下記のbuildOp(userOperationを組み立てる)箇所で上記で設定したmiddlewareの処理を順に実行するので最後に署名処理が呼び出されることになっていました。
https://github.com/stackup-wallet/userop.js/blob/main/src/builder.ts#L208

理解したこと

このサンプルでは configに署名用の秘密鍵をいれてしまうという方法をとっているので
とてもシンプルに実現できていますが、 実際にはこの秘密鍵の管理と署名処理をどのように実装するのかが
セキュリティと利便性のバランスで色々な方式がでてくるところですね。

contract 側

StackupのTutorialはSmartContractを利用するTypescriptの処理でしたが
実際に呼び出される EntryPoint,SimpleAccountのContractは下記のinfinitismのリポのものを利用しているようです。

useroperation

型とuseroperation関連の便利関数が定義されています。
https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/interfaces/UserOperation.sol

SimpleAccount

Factoryから、ユニークとなる何かごとに追加されるContractです。
下記はあくまでサンプルであり、実際には ここにどのような処理をもたせるのか
web3側の一番の考えどころです。 最低限の機能としては useroperationを検証する機能が必要です。

https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/samples/SimpleAccount.sol

entrypoint

useroparation処理をうけつくてれるの投げ込み先です

https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/core/EntryPoint.sol

bundlerは?

bundlerは entrypoint にuseroperationを渡すのが役割であって、bundler自体はweb2のサーバー側で動きます。下記がstackup版のbundlerでGoで書かれています。

https://github.com/stackup-wallet/stackup-bundler

まとめ

ERC-4337 AccountAbstractionで 各アプリケーションで考えるべきは下記2点あると理解できました。

  1. web3にはいるまえ、 どのように SmartAccountに1対1となるユーザー固有の鍵を発行し、署名に利用するか
  2. web3 上で ContractAccount にどんな機能をもたせるのか。 (よく言われているのは秘密鍵をなくしたときのリカバリー処理や、一回の取引の上限チェックなどですが お財布にルールを埋め込むというアイディア次第で無限にひろがります

逆にこれ以外のところは以下の理由からあまり技術的に深堀りする必要はないかと今の時点では理解しました。

  • useroperationはただの型なのでそれにあわせて送るようにするだけ
  • bundler, entrypoint は実装で差がうまれるところではないので提供されているSaaSをつかえばよい
  • paymaster は gas代をだれが負担するかというエコシステム全体のフロー設計が重要でweb3プログラムとしてはシンプルなものになりそう
  • ContractAccountのbuilder(Factory) は とりあえずSampleのロジックをそのまま使えばよい。一意の値からAccountのデプロイ前にアドレスを特定できるということが重要であってそれはサンプルですでに実現できているのであまりそれ以上拡張する必要がない。複雑なロジックはContractAccount側に持たせるべき。

Discussion