🔬

ETHGlobalTokyo 2023 Finalist: BAILOUT 勝手に解説

2023/05/07に公開

4月に開催されたETHGlobalTokyo2023 で Finalistに選ばれていたBAILOUTチームです。Google認証でのContractWalletと、SocialRecoveryと気になる機能をデモしていました。Githubコードを読み解いていきたいと思います。

https://ethglobal.com/showcase/bailout-ia6s1

要約

Bailoutは、資産をEOA、Contract Wallet、およびCold Wallet間で安全に移動させるセキュリティパイプラインです。Bailoutは、メイン資産を格納できるContract Walletと、プライベートキーの紛失時にもCold Walletに資産を簡単に移動できる機能を提供します。これにより、資産管理がスムーズで安全になり、プライベートキーの紛失による被害を最小限に抑えられます。

Bailoutの主な機能は、

  1. EOAから安全なSmart Contract Walletへ簡単に資産を移動することができます。
  2. 緊急時には、ガス料金なしでCold Walletに資産を移動できます。

Bailoutは、ERC-4337、Litプロトコル、Lens API、MetaMaskスナップなどの技術を使用して構築されました。2要素認証は、Google認証を利用したMPCウォレットを作成し、ソーシャルリカバリ機能はLit ActionsとPKPsを使用して実装されています。ただし、MetaMaskスナップの接続に問題があったため、スナップ環境の外で全てのロジックが実装されています。アプリケーションはPolygon Mumbaiにデプロイされています。

個人的な期待

パイプラインということばが具体的に何を指しているのかしらべたいと思います。2要素認証、SocialRecoveryは具体的なやり方を理解しておきたいです。 Lit, Lens等もよく知らないので調べたいと思います。

Github Repository

https://github.com/enu-kuro/bailout

  • account-abstraction/
    • ERC-4337 にそったWalletAccount, WalletAcountFactoryの定義
  • contracts/
    • LitのPKP Helper ABI
    • 上記 WalletAccountのABI
  • google-auth/
    • sample のGoogle認証コード
  • package/site/src/
    • WebAppのコード

PKP (Programmable Key Pairs)

まずreadme で当たり前のように使われているPKPという言葉が気になります。
調べてみると Programmable Key Pairs ということばで、このプロジェクトの根幹になるものでした。
秘密鍵の管理を 個人が管理するのではなく、 web3 のプログラムとして管理する考え方ということです。
https://developer.litprotocol.com/pkp/intro

Lit とは?

Litプロトコルは、しきい値暗号を利用した分散型キー管理ネットワークで、ブロックチェーンとオフチェーンプラットフォーム間でデータの読み書きを可能にするブロックチェーン非依存のミドルウェア層です。Litは、暗号化およびアクセス制御、データの署名(ブロックチェーンへの書き込み)の2つの主要な機能を提供し、Web3アプリケーションの開発をサポートします。

Litプロトコルは次のような特徴を持ちます:

  1. 分散型のキー管理ネットワーク
  2. ブロックチェーン非依存のミドルウェア
  3. 暗号化とアクセス制御のサポート
  4. データの署名とブロックチェーンへの書き込みを容易にする
機能 説明
暗号化とアクセス制御 データの暗号化と復号化を分散型で実現し、プライバシーの保護を可能にする。
プログラマブルキーペア (PKP) 分散型ネットワーク上の検証者によって生成され、NFTとして表現される暗号化キーペア。
Lit Actions PKPの署名条件を定義する不変のJavaScript関数。オフチェーンデータやクロスチェーンデータを利用可能。
Web3アプリケーション開発 DeFi、インフラストラクチャ、Web3ソーシャル、ゲーミングなど、幅広いWeb3アプリケーションの開発をサポート。

MPC-wallet(Multi-party computation) とは?

もうひとつMPC-walletという単語もよく出てきます。Multi-party computation の略ということですがどういうことでしょうか?

下記のAlchemyのBlog記事を要約してみます。
https://www.alchemy.com/overviews/mpc-wallet

MPC(Multi-party computation)とは、複数の参加者がプライベートな情報や秘密データを明かさずに計算を行うことができる暗号技術の一種です。これにより、データのセキュリティとプライバシー保護が大幅に向上し、特にブロックチェーンアプリケーションなどの分散ネットワークにおいて重要な役割を果たしています。

MPCの概要:

  1. プライバシー: 参加者の個人情報はプロトコルの実行後も漏洩しません。
  2. 正確さ: 参加者がプロトコルから逸脱したり情報を共有したりしても、正確な結果が得られます。
  3. 誕生: 1970年代初期に研究が始まり、1980年代に実用化が進みました。
  4. 用途: デジタルオークション、MPCウォレットなど、さまざまな実用的な応用があります。

MPCウォレットとは、MPC技術を使用して暗号資産やデジタル資産のセキュリティを強化するウォレットです。MPCウォレットは、個人、企業、金融機関、政府がデジタル資産を管理する際に強力なセキュリティ保証を提供します。

MPCウォレットとMultisigウォレットの違い:

  • Multisigウォレットは、複数のプライベートキーが必要なデジタル署名を使用します。
  • MPCウォレットは、複数の参加者に1つのプライベートキーを分割して提供します。

MPCウォレットの利点:

  1. 第三者への信頼が不要
  2. データプライバシーの向上
  3. 高い正確性
  4. 単一障害点の排除
  5. ハッキングが困難
  6. コールドストレージへの依存の低減

MPCウォレットの欠点:

  1. 計算オーバーヘッドが高い
  2. 通信コストが高い

主要なMPCウォレット:

  1. ZenGo
  2. Fireblocks
  3. Coinbase

表での説明:

項目 MPCウォレット Multisigウォレット
プライベートキー 複数の参加者に分割 複数のプライベートキーが必要
セキュリティ 強力なセキュリティ保証 セキュリティが高いが、制約がある
プロトコルの普遍性 プロトコル非依存 プロトコル依存
運用の柔軟性 柔軟性が高い 柔軟性が低い
通信コスト 比較的高い 比較的低い
計算オーバーヘッド 高い 低い

総じて、MPCウォレットはセキュリティと運用の柔軟性が重要な要素である場合に適しており、Multisigウォレットはセキュリティが重要でありながら、運用の柔軟性やプロトコルの普遍性がそれほど重要でない場合に適しています。

BAIOUT チームコード 2FAの設定

GoogleAuthとAccountAbstraction Wallet

下記の部分でGoogleAuthのログインが成功したときにそのid_tokenとひもづけたERC-4337 SmartAccount contract をDeployしています。

https://github.com/enu-kuro/bailout/blob/main/packages/site/src/pages/index.tsx#L134

  const handle2FaClick = async () => {
    console.log('handle2FaClick');
    if (!googleCredential?.id_token) {
      googleLogin();
    } else {
      // TODO: mint pkp
      const pkpPublicKey = await create2FaWallet(googleCredential.id_token);
      set2FaPkpPublicKey(pkpPublicKey);
    }

具体的なCreate2FaWallet は以下のコードです。
まずLit のPKPをNFTという形で作成して、
その後 AccountAbstractionの wallet (SmartAccount)をDeployしています。

packages/site/src/utils/snap.ts
export const create2FaWallet = async (credential: string) => {
  const { pkpPublicKey, pkpEthAddress } = await mintPKPWithCredential({
    credential,
  });
  const txHash = await set2FaSnap(pkpEthAddress);
  return pkpPublicKey;
};

PKPNFT の Mint

LitへのPKP NFTとして、
token_id, sub, aud からIDをつくり、それをもとにLitネットワーク上にNFTをMintしています。
pkpHelper.mintNextAndAddAuthMethods をよびだすことで GoogleAuthのidTokenの
sub, aud に対応した 秘密鍵、公開鍵のPairをつくりだして、秘密鍵はだれにも渡さずに分散的に
LidのNodeに管理させたままpubkeyで指定することができるようになったんですね! Litすごい仕組みです。

packages/site/src/snapMock/lit.ts
export async function mintPKPWithCredential({
  credential,
}) {
  const tokenPayload = decodeJwt(credential);
  const idForAuthMethod = utils.keccak256(
    toUtf8Bytes(`${tokenPayload.sub}:${tokenPayload.aud}`),
  );
  return await mintPKP({
    authMethodType: AuthMethodType.GoogleJwt,
    idForAuthMethod,
  });
}

async function mintPKP({
  authMethodType,
  idForAuthMethod,
}) {
    await changeNetwork(ChainId.lit);
    const provider = new ethers.providers.Web3Provider(
      window.ethereum as BaseProvider,
    );
    const accounts = await provider.send('eth_requestAccounts', []);
    const signer = await provider.getSigner();
    const pkpHelper = getPkpHelperContract().connect(signer);
    const pkpNft = getPkpNftContract().connect(signer);
    const mintCost = await pkpNft.mintCost();
    let encodedIdForAuthMethod: string | Uint8Array = idForAuthMethod;
    const tx = (await pkpHelper.mintNextAndAddAuthMethods(
      2,
      [authMethodType],
      [encodedIdForAuthMethod],
      ['0x'],
      [[ethers.BigNumber.from('0')]],
      true,
      true,
      { value: mintCost },
    )) as ContractTransaction;
    const mintReceipt = await tx.wait();
    const tokenId = mintReceipt.logs[0].topics[3];
    const pkpEthAddress = await pkpNft.getEthAddress(tokenId);
    const pkpPublicKey = await pkpNft.getPubkey(tokenId);
    return { pkpPublicKey, pkpEthAddress };
}

ちなみにpkpHelperのContractが実際どのようなContract調べようとしましたが、LitのExplorerではOwner以外は 簡単に調べられないようになっているようです。

ABIはあるので 今回はIFは調べることができます。
https://github.com/enu-kuro/bailout/blob/main/contracts/PKPHelper.json
今回はHelperは、mintしてAuthtypeを設定するということに使っているようです。

WalletAccountのDeploy

WalletAccountは Polygon Test Network (mumbai) へデプロイしています。
ERC-4337 のbundlerとしては stackup を利用しています。
Stackupについては下記も参考にどうぞ。
https://zenn.dev/kozayupapa/articles/28a59189eddee4

ID(address)をGoogleAuthでログインした情報(IdTokenのSub,Aud)を利用しているので、再度同じアカウントでログインすれば同じアドレスになります。そして、そのアドレスを 下記でSecondOwnerに指定しているのがポイントになります。
data: myContract.interface.encodeFunctionData('setSecondOwner', [address])

packages/site/src/snapMock/aaWallet.ts
export const set2Fa = async (address: string) => {
  await changeNetwork(ChainId.mumbai);
  const aaProvider = new ethers.providers.JsonRpcProvider(rpcUrl);
  const ethProvider = new ethers.providers.Web3Provider(
    window.ethereum as unknown as BaseProvider,
  );

  await ethProvider.send('eth_requestAccounts', []);
  const signer = ethProvider.getSigner();
  const accountAPI = new SimpleAccountAPI({
    provider: aaProvider,
    owner: signer,
    entryPointAddress,
    factoryAddress,
  });
  const myAddress = await accountAPI.getAccountAddress();
  const myContract = new Contract(myAddress, WalletAccount.abi, aaProvider);
  const singedUserOp = await accountAPI.createSignedUserOp({
    target: myAddress,
    value: 0,
    data: '0x',
    maxPriorityFeePerGas: 0x2540be400, // 15gwei
    maxFeePerGas: 0x6fc23ac00, // 30gewi
  });
  const client = new HttpRpcClient(
    bundlerUrl,
    entryPointAddress,
    Number(ChainId.mumbai),
  );

  const uoHash = await client.sendUserOpToBundler(singedUserOp);
  const txHash = await accountAPI.getUserOpReceipt(uoHash, 1000, 1000);
  const singedUserOp2 = await accountAPI.createSignedUserOp({
    target: myAddress,
    data: myContract.interface.encodeFunctionData('setSecondOwner', [address]),
    maxPriorityFeePerGas: 0x2540be400, // 15gwei
    maxFeePerGas: 0x6fc23ac00, // 30gewi
  });
  const uoHash2 = await client.sendUserOpToBundler(singedUserOp2);
  const txHash2 = await accountAPI.getUserOpReceipt(uoHash2, 10000);
  return txHash;
};

そして ERC-4337で最も重要になる SmartAccountが署名を検証する部分が下記になります。
残念ながらまだsecond owner の署名検証は実装途中ですが コンセプトはよく分かりました。

https://github.com/enu-kuro/bailout/blob/main/account-abstraction/contracts/samples/WalletAccount.sol#L196

BAIOUT チームコード transfer

上記で設定したWalletAccountにtranferを送るところでLitを利用しています。
署名をするときにgoogleAuthのid_tokenをどのように利用しているのか中を追っていきます。

packages/site/src/pages/index.tsx

  const handleTransferClick = async () => {
    const { userOpHash, userOp } = await createUnsignedUserOp({
      targetAddress,
      sendValue,
    });
    const pkpSignature = await signWithPkp({
      message: userOpHash,
      pkpPublicKey: get2FaPkpPublicKey(),
      accessToken: googleCredential.id_token,
    });
    const txHash = await transfer({ userOp, signature: pkpSignature });
  };

まず、Litに呼び出すための署名として、CheckAndSignMessage()では、BrowserのWalletがNetworkをサポートしているかと、自分自身のAddressに対して署名してもらい、それをLocalStorageに保存しています。

https://developer.litprotocol.com/sdk/explanation/walletsigs/authsig/#obtaining-the-authsig

Message自体に署名するときは、LitJsSdk.LitNodeClient で LitのNodeに接続して
litNodeClient.executeJs Lit Actionで実行させているんですね。
LitActionの組み込み LitActions.ethPersonalSignMessageEcdsa
メッセージ自体への署名を行っています。このときに LocalStorageに保存しておいた Litに保存した秘密鍵に対応するPubKeyを指定することで 秘密鍵を受け渡すことなく署名ができるようです。(Litすごい。)

packages/site/src/utils/lit.ts
const litActionCodeForSign = `
const go = async () => {
  const fromAddress = ethers.utils.computeAddress(publicKey);
  LitActions.setResponse({ response: JSON.stringify({fromAddress: fromAddress}) });
  const sigShare = await LitActions.ethPersonalSignMessageEcdsa({ message, publicKey, sigName });
};

go();
`;

export const signWithPkp = async ({
  message,
  pkpPublicKey,
  accessToken,
}) => {
  const litNodeClient = new LitJsSdk.LitNodeClient({
    litNetwork: 'serrano',
    debug: true,
  });

  await litNodeClient.connect();
  const authSig = await LitJsSdk.checkAndSignAuthMessage({ chain: 'mumbai' });
  const results = await litNodeClient.executeJs({
    code: litActionCodeForSign,
    authSig,
    jsParams: {
      publicKey: pkpPublicKey,
      message,
      sigName: 'sig1',
    },
    authMethods: [
      {
        accessToken,
        authMethodType: 6,
      },
    ],
  });

  return results.signatures.sig1.signature as string;
};

BAIOUT チームコード SocialRecovery

SocialRecoveryの設定

下記のようにSocialRecoveryAddress( もしものときの資産のescape先)と、
ipfs上のContensIDを指定しています。

packages/site/src/pages/index.tsx
 const handleSetupSocialRecoveryClick = async () => {
    const { pkpPublicKey, pkpEthAddress, txHash } = await setupSocialRecovery({
      socialRecoveryAddress,
      ipfsCid,
    });
    setSocialRecoveryPkpEthAddress(pkpEthAddress);
    setSocialRecoveryPkpPublicKey(pkpPublicKey);
    setPkpIpfsCid(ipfsCid);
  };

ipfsには下記のコードテンプレートをもとに編集したものを登録すれば良い。

https://github.com/enu-kuro/bailout/blob/main/packages/site/src/utils/socialRecoveryLitAction.ts

setupSocialRecovery のなかでは下記のようにLitのPKPにipfsCidでmintしています。
この場合はIpfsCidに応じてpkpPublicKey, pkpEthAddressが払い出されるので、
ipfsCidに含まれれるコード自体がrecoveryの実行権限もつというような考え方ですね。面白いです。
そのpkpEthAddressをWalletAccountに ERC-4337 の userOperationでRecoveryAddress設定しています。

packages/site/src/utils/snap.ts
export const setupSocialRecovery = async ({
  socialRecoveryAddress,
  ipfsCid,
}) => {
  const { pkpPublicKey, pkpEthAddress } = await mintPKPWithIpfsCid({ ipfsCid });
  const txHash = setupSocialRecoverySnap({
    targetAddress: socialRecoveryAddress,
    proverAddress: pkpEthAddress,
  });
  return { pkpPublicKey, pkpEthAddress, txHash };
};

このリクエスト自体に署名をするのは、もともとのEOAのwalletの秘密鍵です。
ERC-4337の一般的なuserOperationをbundlerに送る方式で処理しています。
これで bundler -> entrypoint -> WalletAccount に下記がとどきます。
'setupSocialRecovery', [targetAddress, proverAddress]

packages/site/src/snapMock/aaWallet.ts
export const setupSocialRecovery = async ({
  targetAddress,
  proverAddress,
}) => {
  await changeNetwork(ChainId.mumbai);
  const aaProvider = new ethers.providers.JsonRpcProvider(rpcUrl);
  const ethProvider = new ethers.providers.Web3Provider(
    window.ethereum as unknown as BaseProvider,
  );

  await ethProvider.send('eth_requestAccounts', []);
  const signer = ethProvider.getSigner();
  const accountAPI = new SimpleAccountAPI({
    provider: aaProvider,
    owner: signer,
    entryPointAddress,
    factoryAddress,
  });
  const myAddress = await accountAPI.getAccountAddress();
  const myContract = new Contract(myAddress, WalletAccount.abi, aaProvider);
  const op = await accountAPI.createSignedUserOp({
    target: myAddress,
    data: myContract.interface.encodeFunctionData('setupSocialRecovery', [
      targetAddress,
      proverAddress,
    ]),
    maxPriorityFeePerGas: 0x2540be400, // 15gwei
    maxFeePerGas: 0x6fc23ac00, // 30gewi
  });
  const client = new HttpRpcClient(
    bundlerUrl,
    entryPointAddress,
    Number(ChainId.mumbai),
  );
  const uoHash = await client.sendUserOpToBundler(op);
  const txHash = await accountAPI.getUserOpReceipt(uoHash);
  return txHash;
};

SocialRecoveryの実行

事前Setupの時に登録しておいたipfsのRecovery判定Codeを実行し、
Recoveryの署名をしてもらい それを直接WalletAccountに送信しています。
この処理自体は、ipfsId, pkpPublicKeyという秘密ではない情報があれば実行できるということですね。
どのような条件が整えばrecoveryを実行するのかというコードをLit Actionで実行できることで
柔軟なRecoveryが実装できるということがよくわかりました。

packages/site/src/utils/lit.ts
export const executeSocialRecovery = async ({
  pkpPublicKey,
  ipfsId,
}) => {
  await changeNetwork(ChainId.mumbai);
  const litNodeClient = new LitJsSdk.LitNodeClient({
    litNetwork: 'serrano',
    debug: true,
  });
  await litNodeClient.connect();
  const authSig = await LitJsSdk.checkAndSignAuthMessage({ chain: 'mumbai' });
  const results = await litNodeClient.executeJs({
    ipfsId,
    authSig,
    jsParams: {
      publicKey: pkpPublicKey,
      sigName: 'sig1',
    },
  });
  const socialRecoverytxParams = results.response as UnsignedTransaction;
  const proverSignature = results.signatures.sig1.signature;
  const provider = new ethers.providers.Web3Provider(
    window.ethereum as BaseProvider,
  );
  const serializedTx = serialize(socialRecoverytxParams, proverSignature);
  try {
    const tx = await provider.sendTransaction(serializedTx);
    const receipt = await tx.wait();
    return receipt;
  } catch (e) {
    console.log('error', e);
  }
  return '';
};

実際のRecovery処理は 資産をすべてEscape先のアドレスに移動するという内容になっています。

account-abstraction/contracts/samples/WalletAccount.sol
  function executeSocialRecovery() external {
    _onlyPKP();
    require(escapeAddress != address(0), 'escape is not set');
    payable(escapeAddress).transfer(address(this).balance);
    // ERC20
    // IEC20(address).transfer(escapeAddress, 0);
    // ERC721
    //IEC721(address).transferFrom(address(this), escapeAddress, 0);
  }

理解まとめ

最初の説明にあった 下記のパイプラインが具体的にどう実装されているのか理解できました。

Bailoutは、資産をEOA、Contract Wallet、およびCold Wallet間で安全に移動させるセキュリティパイプラインです。Bailoutは、メイン資産を格納できるContract Walletと、プライベートキーの紛失時にもCold Walletに資産を簡単に移動できる機能を提供します。

  1. ContractWalletにSecondOwnerやRecoveryの仕組みを追加する(ERC-4337)
  2. ContractWalletの最初の作成はEOAでおこなう(ERC-4337 userOperation)
  3. LitのPKPを利用して作成したAddressをContractWalletのSecondOwnerに登録する(Lit PKP)
  4. その後Transferなどの処理は LitのPKPを利用 (Lit Action)
  5. Recoveryの事前設定 処理内容や 、ColdWalletアドレスは事前にEOAで署名して登録 (ERC-4337 userOperation, ipfs, Lit PKP)
  6. Recoveryの実行は ipfsのコードで判断署名してColdWalletに資産移動(Lit Action)

勉強すべき箇所の多い、すばらしい内容のコードでした。

Discussion