Smart Contract Solidity ブロックチェーン

[Account Abstraction] zKatanaで特定の値を含めてコントラクトアカウント生成

かるでね

こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。

今回は「特定の値を含めてコントラクトアカウントを生成していく手順」を紹介したいと思います。

そして「zKatana」というEthereumのLayer2である「Astar zkEVM」のテストネットでデプロイをしていこうと思います。

単純にコントラクトアカウントを生成するのは簡単なのですが、特定の値を含めるとなると一工夫必要なため、実際に筆者が実装した例を元に紹介していきます。

Account Abstractionとは

Account Abstractionについて簡単に説明すると以下のようになります。

コントラクトアカウントからトランザクションを起こせるようにする。

Ethereumにおいて、大きく分けると以下のように2つのアカウントがあります。

アカウントの種類

  • EOAアカウント
  • コントラクトアカウント

それぞれのアカウントには以下のような特徴があります。

EOAアカウント

EOAアカウント

  • 秘密鍵を所有している。
  • トランザクションを起こすことができる。

コントラクトアカウント

コントラクトアカウント

  • 秘密鍵がない。
  • コードが書かれている。

コントラクトアカウントの方は秘密鍵がないため、トランザクションの起点になることができません。

そこで、以下のようなことの実現を目指しているのがAccount Abstractionです。

「コントラクトアカウントからもトランザクションを起こせるようにし、EOAアカウントとコントラクトアカウントとの差をなくす」

より詳しくは以下の記事で書かれています。

ERC4337

前章で紹介したAccount AbstractionをEthereumに実装するとなると大きな変更が必要になります。

そのため、今すぐにAccount AbstractionをEthereumに実装することはできません。

ただ、Account Abstractionを現状のEthereumでも実装したいというモチベーションから提案されたので、「ERC4337」です。

詳しくは以下の記事にまとめられているので、気になる方は読んでみてください。

コントラクトアカウント生成

では、基礎知識を把握した上で、早速特定の値を含んでコントラクトアカウントを生成していきましょう!

実装概要

ERC4337などの仕組みを使用してコントラクトアカウントを生成するとき、主に以下の2つの方法があります。

コントラクトアカウント生成方法

  • 全部自分で作成する。
  • 何らかのサービスを使用する。

全部自分で実装するのは大変なので、基本的には何らかのサービスを使用することがほとんどです。

ただ、このとき一点以下のような問題があります。

サービスが対応していないチェーン上でコントラクトアカウントを生成できない。

対応チェーンを自分でカスタムすることは基本的にはできず、サービスが対応しているチェーンに依存してしまう点が注意するべき点です。

そのため、公開されたばかりのチェーンやマイナーなチェーンだと、各サービスが対応していないことが多いです。

今回コントラクトアカウントをデプロイしたいチェーンは、「zKatana」という「Astar zkEVM」のテストネットです。

2023年12月現在では、まだメインネットも公開されていないため、対応しているコントラクトアカウント生成サービスがありませんでした。

ただ、「Astar zkEVM」の開発を主導している「Stake Technologies」が対応を迅速に行なってくれたため、Gelatoというサービスを使用することで実装できるようになっていました。

Gelatoについては以下の記事を参考にしてください。

Gelatoはさまざまな機能を実装できるサービスで、コントラクトアカウントもSafeという別のサービスと連携して作成することができるようになっています。

このGelatoを使用して、「zKatana」上にコントラクトアカウントをデプロイできるようにしたライブラリが以下になります。

仕組みとしては以下のような構成になっています。

Gelato」というサービスが「Safe」を組み込んでいます。

その「Gelato」を元に、コントラクトアカウント生成機能を「zKatana」に対応させたのが、 「zKatana-Gelato」ライブラリで(正式なライブラリ名は異なりますが、説明のためわかりやすくしています)。

使用するサービスやライブラリについて説明したので、早速実装に移っていきます!

コントラクトアカウントの実装

単純にコントラクトアカウントを実装したいだけであれば、以下のドキュメントに従って簡単に実装できます。

特定の値を含んでコントラクアカウントの実装

では、最後にこの記事の本題である「特定の値を含んでコントラクトアカウントの生成」を実装してきましょう。

以下が実装コードです。

import { ethers } from 'ethers';
import {
  PRIVATE_KEY,
  PROVIDER_URL,
  CONTRACT_ADDRESS,
  GELATO_RELAY_API_KEY,
} from './config';
import {
  EthAdapter,
  MetaTransactionData,
  MetaTransactionOptions,
  OperationType,
  RelayTransaction,
} from '@safe-global/safe-core-sdk-types';
import crypto from 'crypto';
import Safe, {
  EthersAdapter,
  SafeAccountConfig,
  SafeFactory,
  getSafeContract,
} from 'zkatana-gelato-protocol-kit';
import { GelatoRelayPack } from 'zkatana-gelato-relay-kit';
import ContractInfo from './abi.json';

const encryptSha256 = (value: string) => {
  console.log(`value: ${value}`);
  const hash = crypto.createHash('sha256').update(value, 'utf8').digest('hex');
  const decimalHash = String(BigInt(`0x${hash}`));
  return decimalHash;
};

const common = async (salt: string) => {
  const RPC_URL = PROVIDER_URL;
  const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
  console.log('Network: ', await provider.getNetwork());
  const signer = new ethers.Wallet(PRIVATE_KEY, provider);
  const ethAdapterOwner = new EthersAdapter({
    ethers,
    signerOrProvider: signer,
  }) as EthAdapter;
  const safeFactory = await SafeFactory.create({ ethAdapter: ethAdapterOwner });
  const safeAccountConfig: SafeAccountConfig = {
    owners: [await signer.getAddress()],
    threshold: 1,
  };

  const saltNonce = encryptSha256(salt);
  console.log(`saltNonce: ${saltNonce}`);
  return {
    safeFactory: safeFactory,
    safeAccountConfig: safeAccountConfig,
    saltNonce: saltNonce,
    ethAdapterOwner: ethAdapterOwner,
    signer: signer,
  };
};

const createSafeWallet = async (salt: string) => {
  const { safeFactory, safeAccountConfig, saltNonce } = await common(salt);

  const safeSdk = await safeFactory.deploySafe({
    safeAccountConfig,
    saltNonce,
  });

  const safeAddress = await safeSdk.getAddress();
  console.log(`safeAddress: ${safeAddress}`);

  return safeAddress;
};

長いので分けて解説していきます。

インポート

import { ethers } from 'ethers';
import {
  PRIVATE_KEY,
  PROVIDER_URL,
  CONTRACT_ADDRESS,
  GELATO_RELAY_API_KEY,
} from './config';
import {
  EthAdapter,
  MetaTransactionData,
  MetaTransactionOptions,
  OperationType,
  RelayTransaction,
} from '@safe-global/safe-core-sdk-types';
import crypto from 'crypto';
import Safe, {
  EthersAdapter,
  SafeAccountConfig,
  SafeFactory,
  getSafeContract,
} from 'zkatana-gelato-protocol-kit';
import { GelatoRelayPack } from 'zkatana-gelato-relay-kit';
import ContractInfo from './abi.json';

ここでは必要なライブラリやファイルをインポートしています。

config.tsファイルの中身は以下のように定義されています。

import * as dotenv from 'dotenv';
dotenv.config();

export const PRIVATE_KEY = process.env.NEXT_PUBLIC_PRIVATE_KEY as `0x${string}`;
export const GELATO_RELAY_API_KEY = process.env.NEXT_PUBLIC_GELATO_RELAY_API_KEY;
export const PROVIDER_URL = 'https://rpc.zkatana.gelato.digital';
export const CONTRACT_ADDRESS = '...';

ポイント

  • PRIVATE_KEY
    • 環境変数に定義されているウォレットの秘密鍵。
  • GELATO_RELAY_API_KEY
    • コントラクトアカウントを使用して、ガス代を負担するときに必要となるGelatoのAPI Keyです。
    • 設定とAPI Keyを発行できるダッシュボードは以下になります。
    • https://app.gelato.network/relay
  • PROVIDER_URL
  • CONTRACT_ADDRESS
    • この後のおまけで取り上げるトークン送付時のトークンコントラクトアドレス。

Gelato」のダッシュボードの定義は以下のようになっています。

import ContractInfo from './abi.json';

上記では、トークン送付時のコントラクトのABIファイルを読み込んでいます。

ハッシュ化

const encryptSha256 = (value: string) => {
  console.log(`value: ${value}`);
  const hash = crypto.createHash('sha256').update(value, 'utf8').digest('hex');
  const decimalHash = String(BigInt(`0x${hash}`));
  return decimalHash;
};

特定の値を含んでコントラクトアカウントを生成するのですが、直接含むよりは一度ハッシュ化した方が色々安全と考え、上記で引数で受け取った値をハッシュ化しています。

共通処理

const common = async (salt: string) => {
  const RPC_URL = PROVIDER_URL;
  const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
  console.log('Network: ', await provider.getNetwork());
  const signer = new ethers.Wallet(PRIVATE_KEY, provider);
  const ethAdapterOwner = new EthersAdapter({
    ethers,
    signerOrProvider: signer,
  }) as EthAdapter;
  const safeFactory = await SafeFactory.create({ ethAdapter: ethAdapterOwner });
  const safeAccountConfig: SafeAccountConfig = {
    owners: [await signer.getAddress()],
    threshold: 1,
  };

  const saltNonce = encryptSha256(salt);
  console.log(`saltNonce: ${saltNonce}`);
  return {
    safeFactory: safeFactory,
    safeAccountConfig: safeAccountConfig,
    saltNonce: saltNonce,
    ethAdapterOwner: ethAdapterOwner,
    signer: signer,
  };
};

上記にコントラクトアカウントの作成やアドレスの取得、トークン送付時の共通処理を定義しています。

実行するコントラクトを定義したり、特定の値、コントラクトアカウント生成時の設定などを返すようにしています。

コントラクトアカウント生成

const createSafeWallet = async (salt: string) => {
  const { safeFactory, safeAccountConfig, saltNonce } = await common(salt);

  const safeSdk = await safeFactory.deploySafe({
    safeAccountConfig,
    saltNonce,
  });

  const safeAddress = await safeSdk.getAddress();
  console.log(`safeAddress: ${safeAddress}`);

  return safeAddress;
};

ここでコントラクトアカウントを生成しています。

safeFactory.deploySafe...の部分で生成しています。

生成できた後、コントラクトアカウントのアドレスを返しています。

ここだけ見るとめちゃくちゃ簡単です。

以上が「特定の値を含んでコントラクトアカウントを生成する」手順です。

Dappsを作る時はこれだけでは足りず、コントラクトの実行なども必要になってくるため、次の章でコントラクトの実行まで取り上げようと思います。

コントラクトアカウントの実行

前章でコントラクトアカウントの生成ができたので、実際にコントラクトを実行するコードを紹介しようと思います。

コード自体は前章の続きになるので、前章をしっかり見てからこの章を見てください。

コントラクトアカウントアドレスの取得

まずはコントラクトアカウントのアドレスを取得します。

コードは以下になります。

const getSafeWallet = async (salt: string) => {
  const { safeFactory, safeAccountConfig, saltNonce, ethAdapterOwner, signer } =
    await common(salt);

  const safeWalletAddress = await safeFactory.predictSafeAddress(
    safeAccountConfig,
    saltNonce
  );

  const safeSDK = await Safe.create({
    ethAdapter: ethAdapterOwner,
    safeAddress: safeWalletAddress,
  });

  console.log(`safeSDK address: ${await safeSDK.getAddress()}`);

  return { safeSDK, signer, ethAdapterOwner };
};

まずは共通処理が定義されている、common関数を実行して必要な情報を取得します。

const safeWalletAddress = await safeFactory.predictSafeAddress(
    safeAccountConfig,
    saltNonce
);

次にアドレス取得に必要な情報を定義します。

const safeSDK = await Safe.create({
    ethAdapter: ethAdapterOwner,
    safeAddress: safeWalletAddress,
});
console.log(`safeSDK address: ${await safeSDK.getAddress()}`);

最後にコントラクトアカウントのアドレスを取得しています。

createという関数を呼んでいますが、これはコントラクトアカウントの生成ではなく、一度作成したコントラクトアカウントを再呼び出ししています。

これでコントラクトアカウントのアドレスの取得は完了です。

トークンの送付

では最後にコントラクトアカウントを使用してトークンの送付を実行していきます。

コントラクトは以下のものを使用しています。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {

    event TokenTransfer(address indexed from, address indexed to, uint256 amount, string longitude, string latitude, string message);

    constructor() ERC20("Token", "TK") {}


    function mint(address from, address to, uint256 amount, string calldata longitude, string calldata latitude, string calldata message) public {
        _mint(to, amount);

        emit TokenTransfer(from, to, amount, longitude, latitude, message);
    }

}

Mint時に色々な値を渡しているだけのシンプルなものです。

では上記のコントラクトをコントラクトアカウントから実行するコードを見ていきます。

export const sendToken = async (
    salt: string,
    to: string,
  message: string,
  longitude: string,
    latitude: string
) => {
  const { safeSDK, signer, ethAdapterOwner } = await getSafeWallet(salt);
  const tokenContract = new ethers.Contract(
    CONTRACT_ADDRESS,
    ContractInfo.abi,
    signer
  );
  console.log(`tokenContract: ${tokenContract}`);
  const aaWalletAddress: string = await safeSDK.getAddress();
  console.log(`aaWalletAddress: ${aaWalletAddress}`);

  const safeTransactionData: MetaTransactionData[] = [
    {
      to: CONTRACT_ADDRESS,
      data: tokenContract.interface.encodeFunctionData('mint', [
        aaWalletAddress,
        to,
        1,
        longitude,
        latitude,
        message,
      ]),
      value: '0',
      operation: OperationType.Call,
    },
  ];

  console.log(`safeTransactionData: ${JSON.stringify(safeTransactionData)}`);

  const relayKit = new GelatoRelayPack(GELATO_RELAY_API_KEY);

  const options: MetaTransactionOptions = {
    gasLimit: "8000000",
    isSponsored: true
  }

  const safeTransaction = await relayKit.createRelayedTransaction({
    safe: safeSDK,
    transactions: safeTransactionData,
    options,
  });

  const safeSingletonContract = await getSafeContract({
    ethAdapter: ethAdapterOwner,
    safeVersion: await safeSDK.getContractVersion(),
  });

  const signedSafeTransaction = await safeSDK.signTransaction(safeTransaction);

  console.log(
    `signedSafeTransaction: ${JSON.stringify(signedSafeTransaction)}`
  );

  const encodedTx = safeSingletonContract.encode("execTransaction", [
    signedSafeTransaction.data.to,
    signedSafeTransaction.data.value,
    signedSafeTransaction.data.data,
    signedSafeTransaction.data.operation,
    signedSafeTransaction.data.safeTxGas,
    signedSafeTransaction.data.baseGas,
    signedSafeTransaction.data.gasPrice,
    signedSafeTransaction.data.gasToken,
    signedSafeTransaction.data.refundReceiver,
    signedSafeTransaction.encodedSignatures(),
  ]);

  const relayTransaction: RelayTransaction = {
    target: aaWalletAddress,
    encodedTransaction: encodedTx,
    chainId: 1261120,
    options,
  };

  const response = await relayKit.relayTransaction(relayTransaction);

  console.log(
    `Relay Transaction Task ID: https://relay.gelato.digital/tasks/status/${response.taskId}`
  );

  console.log(`response: ${JSON.stringify(response)}`);
  return response.taskId
};

急に複雑になってきましたね...。

1つずつ解説していきます。

const { safeSDK, signer, ethAdapterOwner } = await getSafeWallet(salt);
const tokenContract = new ethers.Contract(
    CONTRACT_ADDRESS,
    ContractInfo.abi,
    signer
);
console.log(`tokenContract: ${tokenContract}`);
const aaWalletAddress: string = await safeSDK.getAddress();
console.log(`aaWalletAddress: ${aaWalletAddress}`);

まずは既に生成されているコントラクトアカウントを取得しています。

const safeTransactionData: MetaTransactionData[] = [
    {
      to: CONTRACT_ADDRESS,
      data: tokenContract.interface.encodeFunctionData('mint', [
        aaWalletAddress,
        to,
        1,
        longitude,
        latitude,
        message,
      ]),
      value: '0',
      operation: OperationType.Call,
    },
];

console.log(`safeTransactionData: ${JSON.stringify(safeTransactionData)}`);

const relayKit = new GelatoRelayPack(GELATO_RELAY_API_KEY);

const options: MetaTransactionOptions = {
    gasLimit: "8000000",
    isSponsored: true
}

const safeTransaction = await relayKit.createRelayedTransaction({
    safe: safeSDK,
    transactions: safeTransactionData,
    options,
});

コントラクト実行に必要な情報が定義されています。

const safeSingletonContract = await getSafeContract({
    ethAdapter: ethAdapterOwner,
    safeVersion: await safeSDK.getContractVersion(),
});

const signedSafeTransaction = await safeSDK.signTransaction(safeTransaction);

console.log(
    `signedSafeTransaction: ${JSON.stringify(signedSafeTransaction)}`
);

const encodedTx = safeSingletonContract.encode("execTransaction", [
    signedSafeTransaction.data.to,
    signedSafeTransaction.data.value,
    signedSafeTransaction.data.data,
    signedSafeTransaction.data.operation,
    signedSafeTransaction.data.safeTxGas,
    signedSafeTransaction.data.baseGas,
    signedSafeTransaction.data.gasPrice,
    signedSafeTransaction.data.gasToken,
    signedSafeTransaction.data.refundReceiver,
    signedSafeTransaction.encodedSignatures(),
]);

const relayTransaction: RelayTransaction = {
    target: aaWalletAddress,
    encodedTransaction: encodedTx,
    chainId: 1261120,
    options,
};

コントラクトアカウントからコントラク実行に必要な情報を定義しています。

const response = await relayKit.relayTransaction(relayTransaction);

console.log(
    `Relay Transaction Task ID: https://relay.gelato.digital/tasks/status/${response.taskId}`
);

console.log(`response: ${JSON.stringify(response)}`);
return response.taskId

実際にコントラクトを実行しています。

トランザクション情報が格納されたURLが発行されています。

URLにアクセスすると以下のようにトランザクション詳細が確認できます。

以上がコントラクト実行のコードです。

なかなか難しいところがありますが、ぜひ手元で実装していただければ理解が深まると思います。

参考

Safe、Gelatoなど複数のサービスを使用しているため、実装でわからなくなったときに参考になるドキュメントなどをまとめておきます。

最後に

今回は「zKatanaで特定の値を含めてコントラクトアカウントを生成していく手順」を紹介してきました。

いかがだったでしょうか?

zKatana」以外のチェーンで実装したい場合でも、今回のコードを少し変更すればできると思うので参考にしてください。

もし何か質問などがあれば以下のTwitterなどからDMしてください!

普段はPythonやブロックチェーンメインに情報発信をしています。

Twiiterでは図解でわかりやすく解説する投稿をしているのでぜひフォローしてくれると嬉しいです!

-Smart Contract, Solidity, ブロックチェーン
-, ,