3MIKAN
仮想通貨直コン

EIP-712署名の読み方|domain・nonce・deadline・replay対策を検証

EIP-712のtypes、domain separator、struct hash、最終digestをviemとSolidityで照合し、nonce再利用・期限切れ・chainId・contract不一致をFoundryとAnvilで検証します。

3MIKANのブランドキャラクターがtyped dataのdomain、nonce、deadlineと署名digestを照合する記事画像

EIP-712は、名前の付いたfieldを持つデータへ署名するための標準です。長い16進数だけではなく、「誰が」「どのcontractで」「何を許可するか」を構造として扱えます。

ただし、署名はtransactionではなくても、後から権限を実行する鍵になり得ます。 gasが表示されないことや、walletが項目を読みやすく表示したことは、安全の保証ではありません。

この記事では、typesprimaryTypedomainmessageから最終digestを作る順番を確認します。続いて、nonce再利用、deadline超過、chainId不一致、verifyingContract不一致をFoundry・Anvil・viemのlocal環境で失敗させます。

外部RPC、実在wallet、mainnet署名、実資産は使いません。掲載するaddressと秘密鍵は、Anvilの公開済みlocal test値または架空値だけです。

署名前に固定する7項目

まずはwalletの文章を読む前に、署名要求を次の7項目へ分けます。

項目 確認する内容 ずれた場合
signer どのaccountが署名するか 別accountの権限になる
name / version どのprotocol・どの署名仕様か 互換でないdomainになる
chainId どのchain向けか 別chainのdigestになる
verifyingContract どのaddressが検証するか 別contractのdigestになる
primaryType / types どのstructとfield順を使うか typeHashが変わる
message 誰に何をいくら許すか 意図しないeffectになる
nonce / deadline 何回、いつまで使えるか 再利用または長期利用の余地が残る

permit、注文、投票、ログインでは、同じEIP-712でもmessageの意味が違います。field名だけで判断せず、その署名を提出した後にどのstateが変わるかまで追います。

EIP-712が構造化するもの・保証しないもの

EIP-712は、Solidityのstructに近いtyped dataを決まった手順でhashし、署名対象へ変換します。

主な役割は次の3つです。

  • field名と型を含めてmessageをhashする
  • protocol、chain、検証contractなどをdomainで分離する
  • 同じ入力から同じdigestを決定的に作る

一方、EIP-712の仕様はreplay protectionを自動では提供しません。仕様のSecurity Considerationsにも、同じmessageを2回見たときに拒否するか、同じ結果だけを返す設計はapplication側の責任だとあります。

つまり、「EIP-712形式だから安全」ではありません。何へ署名したかを読みやすくする標準と、署名を何回使えるか決めるapplication stateを分けて考えます。

typed dataは4つの入力に分ける

viemのsignTypedDataへ渡す基本形は、次の4つです。

const domain = {
  name: '3MIKAN Typed Authorization',
  version: '1',
  chainId: 31337,
  verifyingContract,
}

const types = {
  Authorization: [
    { name: 'signer', type: 'address' },
    { name: 'delegate', type: 'address' },
    { name: 'value', type: 'uint256' },
    { name: 'nonce', type: 'uint256' },
    { name: 'deadline', type: 'uint256' },
  ],
}

const primaryType = 'Authorization'

const message = {
  signer,
  delegate,
  value: 123n,
  nonce: 0n,
  deadline,
}
  • types: struct名、field名、型、並び順
  • primaryType: 今回hashする中心のstruct
  • domain: 署名を使うprotocol・chain・contractの境界
  • message: 実際のaddress、数量、nonce、期限

typesは画面の見出しではなく、hashの入力です。fieldの並び順や型が変われば、同じ値を入れても別のdigestになります。

messageでは、delegatevalueという名前だけを信用しません。検証contractのsourceを読み、最終的にその値がtransfer、allowance、注文、投票、ログインsessionのどれへ使われるか確認します。

typeHashから最終digestまでを追う

EIP-712の最終digestは、次の形です。

digest = keccak256(
  0x1901
  || domainSeparator
  || hashStruct(message)
)

ここで0x1901はEIP-191と組み合わせるためのprefixです。後ろへdomainのhashとmessageのhashを32バイトずつ並べ、最後にKeccak-256を取ります。

EIP-712のtypeとmessageからstruct hash、domainからdomain separatorを作り、0x1901と結合して最終digestへ進む流れ
domainとmessageは別々にhashします。walletの表示だけでなく、最終digestがclientとcontractで一致するか確認します。

typeHashはstructの設計図のhash

今回のstructは、次の文字列へ正規化されます。

Authorization(address signer,address delegate,uint256 value,uint256 nonce,uint256 deadline)

この文字列のKeccak-256がAUTHORIZATION_TYPEHASHです。uintのようなaliasではなくuint256を使い、参照する別structがあれば、その定義も決められた順に追加します。

struct hashはtypeHashと各値のhash

Solidityでは、固定長fieldを次のようにABI encodeしてhashできます。

bytes32 structHash = keccak256(
    abi.encode(
        AUTHORIZATION_TYPEHASH,
        authorization.signer,
        authorization.delegate,
        authorization.value,
        authorization.nonce,
        authorization.deadline
    )
);

stringや可変長bytesは、そのままABI encodeするのではなく、内容のKeccak-256を32バイト値として入れます。nested structは、そのstructのhashStructを入れます。

ABIの32バイト境界を先に確認したい場合は、ABI・calldata・event logsを手で読む方法へ戻ると対応しやすくなります。

viemで3段階を照合する

viemでは、domain separator、struct hash、最終digestを別々に計算できます。

import {
  concat,
  hashDomain,
  hashStruct,
  hashTypedData,
  keccak256,
} from 'viem'

const domainSeparator = hashDomain({ domain, types: typesWithDomain })
const messageHash = hashStruct({
  data: message,
  primaryType: 'Authorization',
  types: typesWithDomain,
})

const reconstructed = keccak256(
  concat(['0x1901', domainSeparator, messageHash]),
)

const digest = hashTypedData({
  domain,
  types,
  primaryType: 'Authorization',
  message,
})

console.assert(reconstructed === digest)

この再構成値とhashTypedDataを同じ入力で計算し、一致することを確認します。client側で使ったJSONと、contract側で使うstruct・field順が一つでも違えば一致しません。

domain separatorでchainとcontractを分ける

今回のdomainは、次の4項目を使います。

name
version
chainId
verifyingContract

EIP-712では、必要に応じてsaltも使えます。使わないfieldはdomain型から省略します。独自fieldを勝手に足すのではなく、標準のdomain設計に合わせます。

chainId

chainIdが変わるとdomain separatorが変わります。そのため、chain 31337向けの署名をchain 31338のdomainで検証すると、復元されるsignerが一致しません。

ただし、domainへchainIdを入れなければ、この分離は得られません。walletが現在のnetworkとdomainのchainId不一致を警告・拒否する場合もありますが、最終的には検証側でも期待するdomainを固定します。

verifyingContract

verifyingContractは、署名を検証するcontract addressです。別addressへ同じmessageを渡しても、domain separatorが変わるため同じ署名では通りません。

proxy構成では、通常は利用者が呼び出し、stateを保持するproxy addressをdomainに使います。implementationをupgradeしてもproxy addressとdomain versionを変えなければ、未使用署名が新しい実装でも有効なままになる可能性があります。

署名の意味や検証条件を変えるupgradeでは、domain versionの変更、nonceの移行、既存署名の無効化方針を先に決めます。proxyの仕組みは直コンでproxyとimplementationを確認する順番で分けて説明しています。

nameとversion

nameは署名domainを人が識別する名前、versionは互換性の境界です。UI上の飾りではなくhashへ入ります。

小さな文言修正のたびにversionを変える必要はありません。一方で、同じtype名でも権限の意味や検証contractの挙動が変わるなら、古い署名と互換にするかを明示的に判断します。

nonce・deadline・used stateでreplayを止める

同じdigestへ同じ鍵で作った署名は、何度検証しても暗号学的には同じsignerを復元できます。そのため、実行回数はcontract stateで制限します。

EIP-712署名をchainId、verifyingContract、nonce、deadline、signerの順に検証し、一度だけ実行する5つのgate
署名検証だけで終わらず、effectの実行と同じtransactionでnonceまたはused stateを更新します。

連番nonce

addressごとに次の未使用nonceを保存し、messageのnonceと一致したときだけ実行します。

uint256 expectedNonce = nonces[authorization.signer];
if (authorization.nonce != expectedNonce) {
    revert InvalidNonce(expectedNonce, authorization.nonce);
}

nonces[authorization.signer] = expectedNonce + 1;

OpenZeppelin Contracts 5.xのNoncesも、addressごとに次の未使用nonceを返し、consume時に増やすutilityです。

連番nonceでは、先のnonceを使うと前の未使用署名が通らなくなる設計になります。注文を順不同で使いたい場合は、digestごとのused mapping、bitmap、個別cancel stateなどを検討します。

deadline

deadlineは、「この時刻を過ぎたら使えない」という境界です。今回の検証例は次の条件にしています。

if (block.timestamp > authorization.deadline) {
    revert AuthorizationExpired(authorization.deadline, block.timestamp);
}

この実装ではdeadlineと同じ秒は有効で、1秒でも超えれば失敗します。>=>のどちらを使うかを曖昧にせず、clientとcontractで同じ条件にします。

deadlineがない署名は、nonceを消費・取消しするまで長期間使える可能性があります。deadlineが極端に遠い場合も、実質的な有効期間を人が読める日時へ直します。

effectより前にstateを更新する

署名が外部callやtoken移動を許可する場合、replay防止stateを更新する位置も重要です。検証後、外部callより前にnonceやused stateを更新し、必要ならreentrancy対策を入れます。

今回の検証用contractは外部callも資産移動も行わず、利用済みeventとnonce更新だけを再現します。production contractの安全性を保証するサンプルではありません。

Foundryで5つのcaseを失敗させる

下の再現例はSolidity 0.8.36で完結し、Foundry v1.8.0、EVM Pragueで実行します。外部RPCは使いません。

test 署名したdomain / message contract側の結果
success 現在chain、現在contract、nonce 0、期限内 実行しnonceを1へ更新
reused nonce successと同じ署名を再提出 InvalidNonce
expired deadline 現在時刻より前のdeadline AuthorizationExpired
wrong chainId chainIdだけを+1して署名 InvalidSigner
wrong verifyingContract 別addressのdomainで署名 InvalidSigner

実行コマンドは次の通りです。

cd examples/eip-712
forge fmt --check
forge test -vv

chainIdとcontractが違うcaseでは、signature bytes自体が壊れているわけではありません。別domainのdigestには正しい署名ですが、現在のcontractが作るdigestとは一致しないため拒否されます。

Anvil上でviemとSolidityのdigestを一致させる

Foundryでの確認に加え、空いているlocal portでAnvilを起動します。viem 2.56.0から検証用contractをdeployし、同じtyped dataをclientとcontractでhashします。

const signature = await walletClient.signTypedData({
  domain,
  types,
  primaryType: 'Authorization',
  message,
})

const viemDigest = hashTypedData({
  domain,
  types,
  primaryType: 'Authorization',
  message,
})

const solidityDigest = await publicClient.readContract({
  address: verifyingContract,
  abi,
  functionName: 'digest',
  args: [message],
})

console.assert(viemDigest === solidityDigest)

一致を確認した後だけ、local authorizationを1回実行します。その後、同じnonce、期限切れ、別chain、別contractをsimulateContractし、すべてrevertすることを確認します。

npm run test:contractsを実行すると、EIP-7702とEIP-712のSolidity codeを整形・動作確認した後、このviem + Anvil照合へ進みます。Anvilは検証後に停止し、mainnetやtestnetへ接続しません。

EOAとERC-1271 contract walletは検証経路が違う

EOAは秘密鍵で署名し、digestとsignatureからecrecoverしたaddressを期待するsignerと比較できます。

viemのutility版verifyTypedDataはEOA署名向けです。contract accountを含めて検証する場合は、Public ClientのverifyTypedData actionなど、ERC-1271を扱う経路を使います。

ERC-1271では、contract walletが次の関数を実装し、有効ならmagic value 0x1626ba7eを返します。

function isValidSignature(bytes32 hash, bytes memory signature)
    external
    view
    returns (bytes4 magicValue);

contractは秘密鍵を持たず、owner、threshold、session key、時刻など独自stateで有効性を判断できます。そのため、同じsignatureがblock Nでは有効でも、owner変更や取消し後のblock N+1では無効になる場合があります。

OpenZeppelinのSignatureChecker.isValidSignatureNowは、signerにcodeがあればERC-1271、なければECDSAとして検証します。過去時点の有効性が必要なら、確認blockとcontract stateも固定します。

1-of-1・2-of-3のthreshold、wrong magic、revert、owner変更後の再検証は、ERC-1271でcontract account署名を検証する方法でFoundryの失敗caseまで確認できます。

EIP-7702でcodeを持つaccountの実行文脈は、EIP-7702のEOA・msg.sender・tx.origin検証で確認できます。accountのcode有無だけを、権限や安全性の結論にしないでください。

code reviewで止める署名要求

次のどれかが説明できない場合は、署名せずに止めます。

確認できないもの なぜ止めるか
verifyingContractのsource / proxy signatureがどのeffectへ使われるか不明
chainId 別chainとのdomain分離を確認できない
nonceまたはused state 同じ署名の再利用条件が不明
deadline いつまで提出できるか不明
spender / delegate / recipient 誰が何を実行できるか不明
value / amount / token 権限の数量と対象assetが不明
primaryTypeと全field wallet表示が省略した値を確認できない
EOA / ERC-1271の検証方法 signer判定の経路が不明

表示された文章が自然でも、domainとmessageの実データが意図通りとは限りません。反対に、見慣れないfieldがあるだけで危険と断定もできません。source、ABI、domain、最終effectを同じ入力で照合します。

Permitとtoken allowanceの関係はMetaMaskのSpending cap・approve・revoke・Permitで扱っています。署名要求では、token、spender、value、nonce、deadlineをこの記事のdomain確認へ接続してください。

ERC-2612 Permit、Permit2のordered / unordered nonce、ERC-7674 temporary approvalを同じ再現例で比べる場合は、ERC-20権限4方式の署名・state・失効比較へ進んでください。

最終チェックリスト

  • signing accountを確認した
  • domainのnameとversionを確認した
  • chainIdが現在の対象chainと一致する
  • verifyingContractを公式source・proxy構成と照合した
  • primaryType、field名、型、並び順を確認した
  • messageのaddress、数量、effectを説明できる
  • nonceまたはused stateの消費方法を確認した
  • deadlineを人が読める日時へ直した
  • clientとcontractの最終digestが一致する
  • EOAとERC-1271の検証経路を分けた
  • 署名成功をtransaction成功や安全保証と混同していない

EIP-712を読むときに大切なのは、walletの見出しを暗記することではありません。type、domain、message、application stateを同じdigestへ対応させ、どの条件で一度だけ実行できるか説明することです。

この記事と検証例に広告・affiliateの署名導線はありません。特定wallet、RPC、contractの利用を勧めるものではなく、production実装では独立したsecurity reviewと追加testが必要です。

確認した一次情報