🗝️

ViemとKMSを利用したトランザクション署名

2023/12/18に公開

はじめに

こんにちは! no plan inc. にてエンジニアやってます @somasekiです。
これはno plan inc.の Advent Calendar 2023の18日目の記事です。

今回は、viemというパッケージを利用し、クラウド KMSに保存された鍵でトランザクションに署名して、実行するまでの手順を書いていきます。ethersjsとの比較も行いながらやっていきます。

この記事でわかること

  • クラウド(AWS, GCP)のKMS(Key Management Service)の秘密鍵を使って、トランザクションに署名する方法が、ethersjs, viemそれぞれわかる

KMSを使って署名するには

まず今回の前提として、以下のパッケージを使い、GCP(Google Cloud Platform)のKMSを使って署名することとします。

https://github.com/odanado/cloud-cryptographic-wallet

日本人のodan さんが作成されたもので大変便利でした。

ethersjs(~v5)で実装するのであればこのパッケージ一つで十分です。

viem用に関数を作成する

先ほどのパッケージには、ethers-adapterというetherjsで利用できるようなアダプターが用意されていましたが、viemではそれがないので、自前で作っていきます。とはいってもそんなに難しくはありません。

viemでaccountを使う際に利用するtoAccount関数をラップする形になります。
その際に、signTransactionやsignTypedDataメソッドも自前で書く必要があります。

toKmsAccount関数
to-kms-account.ts
import {
  getAddress,
  type SignableMessage,
  hashMessage,
  hashTypedData,
  type TypedData,
  serializeTransaction,
  type TransactionSerializable,
  fromBytes,
  type Signature,
  type Address,
  type Hex,
  type Hash,
  type SerializeTransactionFn,
  type LocalAccount,
  type HashTypedDataParameters,
  type TypedDataDefinition,
  signatureToHex,
  keccak256,
  SerializedTransactionReturnType,
} from 'viem';

import {SignTransactionReturnType, toAccount} from 'viem/accounts';

import {Bytes, type Signer} from '@cloud-cryptographic-wallet/signer';

export interface SignerConfig {
  signer: Signer;
}

const getAccountAddress = async (signer: Signer): Promise<Address> => {
  const address = (await signer.getPublicKey()).toAddress();
  return getAddress(address.toString());
};

export const toKmsAccount = async (
  config: SignerConfig
): Promise<LocalAccount> => {
  const signer = config.signer;
  const address = await getAccountAddress(signer);

  const signMessage = async ({
    message,
  }: {
    message: SignableMessage;
  }): Promise<Hex> => {
    const hash = Bytes.fromString(hashMessage(message));
    const signature = await signer.sign(hash);
    return signatureToHex({
      r: fromBytes(signature.r.asUint8Array, 'hex'),
      s: fromBytes(signature.s.asUint8Array, 'hex'),
      v: BigInt(signature.v),
    });
  };

  const signTransaction = async <
    TTransactionSerializable extends TransactionSerializable,
  >(
    transaction: TTransactionSerializable,
    args?: {
      serializer?: SerializeTransactionFn<TTransactionSerializable>;
    }
  ): Promise<SignTransactionReturnType<TTransactionSerializable>> => {
    if (!args?.serializer) {
      return signTransaction(transaction, {
        serializer: serializeTransaction,
      });
    }
    const serialized = args.serializer(transaction);
    const hash = keccak256(serialized);
    const signature = await signer.sign(Bytes.fromString(hash));
    const sig: Signature = {
      r: fromBytes(signature.r.asUint8Array, 'hex'),
      s: fromBytes(signature.s.asUint8Array, 'hex'),
      v: BigInt(signature.v),
    };
    return args.serializer(transaction, sig);
  };

  const signTypedData = async <
    const TTypedData extends TypedData | Record<string, unknown>,
    TPrimaryType extends string = string,
  >(
    typedData: TypedDataDefinition<TTypedData, TPrimaryType>
  ): Promise<Hash> => {
    const typedDataParam: HashTypedDataParameters<TTypedData, TPrimaryType> =
      typedData;
    const typedHash = hashTypedData(typedDataParam);
    const signature = await signer.sign(Bytes.fromString(typedHash));
    const sig: Signature = {
      r: fromBytes(signature.r.asUint8Array, 'hex'),
      s: fromBytes(signature.s.asUint8Array, 'hex'),
      v: BigInt(signature.v),
    };
    return signatureToHex(sig);
  };

  return toAccount({
    address,
    signMessage,
    signTransaction,
    signTypedData,
    publicKey: address,
  });
};


署名の実装

signTransaction

ethersjs

import { ethers } from "ethers";
import { EthersAdapter } from "@packages/ethers-adapter/src/ethers-adapter";
import { CloudKmsSigner } from "@packages/cloud-kms-signer/src/cloud-kms-signer";

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const cloudKmsSigner = new CloudKmsSigner(
	process.env.BCP_KMS_NAME as string
);
const ethersAdapter = new EthersAdapter(
	{ signer: cloudKmsSigner },
	provider
);

const toWallet = ethers.Wallet.createRandom();

const tx = {
	to: toWallet.address,
	value: ethers.parseEther("0.001"),
	gasLimit: 1000000,
	nonce: await provider.getTransactionCount(
		await ethersAdapter.getAddress(),
		"latest"
	),
	chainId,
};

const signedTx = await ethersAdapter.signTransaction(tx);

const txResponse = await provider.broadcastTransaction(signedTx);

await txResponse.wait();

viem

import {toKmsAccount} from '../src/to-kms-account';
import {CloudKmsSigner} from '@cloud-cryptographic-wallet/cloud-kms-signer';
import {
  type PrepareTransactionRequestParameters,
  createWalletClient,
  http,
  parseEther,
  parseGwei,
  serializeTransaction,
  TransactionSerializable,
} from 'viem';
import {foundry} from 'viem/chains';

const cloudKmsSigner = new CloudKmsSigner(
	process.env.BCP_KMS_NAME as string
);
const kmsAccount = await toKmsAccount({signer: cloudKmsSigner});
const walletClient = createWalletClient({
  account: kmsAccount,
  chain: foundry,
  transport: http(),
})
const txParams: PrepareTransactionRequestParameters = {
  to: '0x2bA49Aaa16E6afD2a993473cfB70Fa8559B523cF',
  value: parseEther('0.0001'),
  gasPrice: parseGwei('1'),
  chain: foundry,
  account: kmsAccount,
};
const txRequest = await walletClient.prepareTransactionRequest(txParams);
const txSerializable: TransactionSerializable = {
  ...txRequest,
  chainId: await walletClient.getChainId(),
};
const serializedTransaction = await walletClient.signTransaction(txSerializable);
const txHash = await walletClient.sendRawTransaction({
    serializedTransaction,
});

signTypedData

ethersjs

import { CloudKmsSigner } from "@packages/cloud-kms-signer/src/cloud-kms-signer";
import { EthersAdapter } from "@packages/ethers-adapter/src/ethers-adapter";
import dotenv from "dotenv";
import { ethers } from "ethers";

dotenv.config();

const provider = new ethers.JsonRpcProvider(process.env.RPC);
const cloudKmsSigner = new CloudKmsSigner(process.env.BCP_KMS_NAME as string);
const ethersAdapter = new EthersAdapter({ signer: cloudKmsSigner }, provider);

const typedData = {
	types: {
		EIP712Domain: [
		  { name: "name", type: "string" },
		  { name: "version", type: "string" },
		  { name: "chainId", type: "uint256" },
		  { name: "verifyingContract", type: "address" },
		],
		Person: [
		  { name: "name", type: "string" },
		  { name: "wallet", type: "address" },
		],
		Mail: [
		  { name: "from", type: "Person" },
		  { name: "to", type: "Person" },
		  { name: "contents", type: "string" },
		],
	},
	primaryType: "Mail",
	domain: {
		name: "Ether Mail",
		version: "1",
		chainId: 1,
		verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
	},
	message: {
		from: {
		  name: "Cow",
		  wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
		},
		to: {
		  name: "Bob",
		  wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB",
		},
		contents: "Hello, Bob!",
	},
};

const signature = await ethersAdapter.signTypedData(typedData.domain, typedData.types, typedData.message);

viem

import {toKmsAccount} from '../src/to-kms-account';
import {CloudKmsSigner} from '@cloud-cryptographic-wallet/cloud-kms-signer';
import {
  type PrepareTransactionRequestParameters,
  createPublicClient,
  createWalletClient,
  http,
  parseEther,
  parseGwei,
  type TypedDataDefinition,
} from 'viem';
import {foundry} from 'viem/chains';

const cloudKmsSigner = new CloudKmsSigner(process.env.BCP_KMS_NAME!);
const kmsAccount = await toKmsAccount({signer: cloudKmsSigner});
const walletClient = createWalletClient({
  account: kmsAccount,
  chain: foundry,
  transport: http(),
})
const typedData: TypedDataDefinition = {
  types: {
    EIP712Domain: [
      {name: 'name', type: 'string'},
      {name: 'version', type: 'string'},
      {name: 'chainId', type: 'uint256'},
      {name: 'verifyingContract', type: 'address'},
    ],
    Person: [
      {name: 'name', type: 'string'},
      {name: 'wallet', type: 'address'},
    ],
    Mail: [
      {name: 'from', type: 'Person'},
      {name: 'to', type: 'Person'},
      {name: 'contents', type: 'string'},
    ],
  },
  primaryType: 'Mail',
  domain: {
    name: 'Ether Mail',
    version: '1',
    chainId: 1,
    verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
  },
  message: {
    from: {
      name: 'Cow',
      wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',
    },
    to: {
      name: 'Bob',
      wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
    },
    contents: 'Hello, Bob!',
  },
};
const signature = await walletClient.signTypedData(typedData);

おわりに

ViemにはCloud KMSを利用した署名の関数が用意されていないので少し苦戦しましたが、一旦できてしまえばかなり便利だと思いました。 Cloud KMSの秘密鍵 x Viemを利用する際はぜひ参考にしてみてください。

no plan株式会社について

  • no plan株式会社は 「テクノロジーの力でZEROから未来を創造する、精鋭クリエイター集団」 です。
  • ブロックチェーン/AI技術をはじめとした、Webサイト開発、ネイティブアプリ開発、チーム育成、などWebサービス全般の開発から運用や教育、支援なども行っています。よくわからない、ふわふわしたノープラン状態でも大丈夫!ご一緒にプランを立てていきましょう!
  • no plan株式会社について
  • no plan株式会社 | web3実績
  • no plan株式会社 | ブログ一覧
    エンジニアの採用も積極的に行なっていますので、興味がある方は是非ご連絡ください!
  • CTOのDMはこちら

Discussion