👻

[EIP-712] の理解に躓いたので解説記事を書いてみた

2024/04/20に公開
2

こんにちは!ブロックチェーンエンジニアの Dr. Doru です!

今回はNFT関連のスマートコントラクトを実装していた際に出てきた「EIP712」規格の理解に時間がかかってしまったので、学びを記事にさせていただきました。

Hardhat、ethers.js(6.12.0)、OpenZeppelinを用いてコントラクトとテストを実装してあります。詳しくはこちらのgithubのレポジトリをチェックして見てください。ethers.jsのv6はなかなかソースが少なくて苦戦しがち、、、

EIP712とは

まずEIP712とは、構造化されたデータにユーザーが署名し、検証するのに使われる規格になります。この規格を用いることで、メタマスクなどのウォレットで署名を行う際に署名する内容をより正確に知ることができるようになります。

domainSeparatorとは

domainSeparator(ドメインセパレータ)は、EIP712Domain(EIP712ドメイン)と呼ばれる構造体のハッシュです。

もう少し細かく見ていくと

bytes32 private constant TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");

このようにEIP712Domainに関する構造データがハッシュ化されています。

そして関数の定義のハッシュと具体的な変数値をもとにさらにハッシュを作成します。

function _buildDomainSeparator() private view returns (bytes32) {
  return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this)));
}

検証したい関数の構造データ作成

今回はMINT関数検証をするのでMINT関数を構造化データとします。

bytes32 public constant MINT_TYPEHASH = keccak256("Mint(address to,uint256 tokenId,uint256 fid)")

domainSeparatorとMINT関数の構造化データの合成

"openzeppelin-contracts/contracts/utils/cryptography/EIP712.sol"内の_hashTypedDataV4関数を用いてdomainSeparatorと作成したMINT関数の構造データを合成します。

function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
  return MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), structHash);
}

toTypedDataHash関数

具体的な合成処理は"openzeppelin-contracts/contracts/utils/cryptography/MessageHashUtils.sol"内のこちらのtoTypedDataHash関数で処理が行われます。

function toTypedDataHash(bytes32 domainSeparator, bytes32 structHash) { 
   internal pure returns (bytes32 digest) {
        /// @solidity memory-safe-assembly
        assembly {
            let ptr := mload(0x40)
            mstore(ptr, hex"19_01")
            mstore(add(ptr, 0x02), domainSeparator)
            mstore(add(ptr, 0x22), structHash)
            digest := keccak256(ptr, 0x42)
        }
    }
}

署名検証

署名検証には、"openzeppelin-contracts/contracts/utils/cryptography/SignatureChecker.sol"内のisValidSignatureNow関数を使用します。引数として signerアドレス、toTypedDataHash関数を用いて作成したhash、signatureを渡し、正しい署名であるかどうかを検証します。

function isValidSignatureNow(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
  if (signer.code.length == 0) {
    (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature);
    return err == ECDSA.RecoverError.NoError && recovered == signer;
  } else {
    return isValidERC1271SignatureNow(signer, hash, signature);
  }
}

Hardhatでのテスト方法

Hardhatでのテスト方法は思ったよりもシンプルでした。

// domainを定義
const domain = {
  name: 'Eip712 Test',
  version: '2',
  chainId: 31337n, //hardhatデフォルトのchainId
  verifyingContract: Eip712.target,
};

// 署名するメッセージの型定義、今回MINT関数
const types = {
  Mint: [
    { name: 'to', type: 'address' },
    { name: 'tokenId', type: 'uint256' },
    { name: 'fid', type: 'uint256' },
  ],
};
  
// 署名するメッセージのデータ
const message = {
  to: to,
  tokenId: tokenId,
  fid: fid,
};

// 署名の作成
const sig = await owner.signTypedData(domain, types, message);

// mint関数を呼び出す
expect(await Eip712.mint(to, tokenId, fid, sig))
  .to.emit(Eip712, "mint")
  .withArgs(to, tokenId, fid); // Mintイベントが期待通りに発火するか検証

mint関数内で署名が検証されるのでこちらのテストが通れば署名が正しく作成できているということになります!

まとめ

EIP712の実装を理解するのはなかなか大変でしたが、署名検証の方法としてユーザーが分かりやすいようなメッセージを示すことは大切なことなので学べてよかったなという感じです。

間違っている情報などありましたらご指摘いただけたらと思います。

読んでいただきありがとうございました!
また次の機会に!アディオス!

Discussion

HarukiHaruki

僕もメタトランザクション実装した時に躓きました笑!
難しいですよね!

Dr. DoruDr. Doru

ですよね!
それにethers.jsのバージョン6は変更点多くてまだ慣れないですね、、、