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

EIP-712は、名前の付いたfieldを持つデータへ署名するための標準です。長い16進数だけではなく、「誰が」「どのcontractで」「何を許可するか」を構造として扱えます。
ただし、署名はtransactionではなくても、後から権限を実行する鍵になり得ます。 gasが表示されないことや、walletが項目を読みやすく表示したことは、安全の保証ではありません。
この記事では、types、primaryType、domain、messageから最終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する中心のstructdomain: 署名を使うprotocol・chain・contractの境界message: 実際のaddress、数量、nonce、期限
typesは画面の見出しではなく、hashの入力です。fieldの並び順や型が変われば、同じ値を入れても別のdigestになります。
messageでは、delegateやvalueという名前だけを信用しません。検証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を取ります。
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で制限します。
連番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が必要です。
確認した一次情報
- EIP-712 Typed structured data hashing and signing確認日: 2026/08/30
- viem signTypedData確認日: 2026/08/30
- viem hashTypedData確認日: 2026/08/30
- viem verifyTypedData確認日: 2026/08/30
- OpenZeppelin Contracts 5.x EIP712確認日: 2026/08/30
- OpenZeppelin Contracts 5.x Nonces確認日: 2026/08/30
- ERC-1271 Contract Signature Validation確認日: 2026/08/30
- Solidity 0.8.36 documentation確認日: 2026/08/30
- Foundry v1.8.0確認日: 2026/08/30



