要点:EIP-712 は構造化データのハッシュに署名します:keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message))。ドメイン(名前、バージョン、チェーン ID、検証コントラクト)は、その署名がどのチェーン上のどのコントラクト向けかを示し、メッセージは型付きの構造体です。署名する前に、次の 4 点を確認してください。verifyingContract と chainId(想定どおりのコントラクトか、今接続しているチェーンか)、primaryType(承認か、注文か、ログインか)、spender または受取人、そして金額と期限です。最大額とはるか先の期限を指定した ERC-2612 の Permit や Uniswap の Permit2 のメッセージは無制限の承認であり、その署名を持っている人なら誰でも後から有効化できます。ハードウェアウォレットにドメインハッシュとメッセージハッシュしか表示されない場合は、別のデバイスで両方を計算し直して比較してください。
バイト列の中身
EIP-712(ステータスは Final)は、次の 3 つの要素を定義しています。
encodeType:構造体の型を文字列で表したもの(Mail(Person from,Person to,string contents))で、その後ろに、参照しているすべての構造体の型を名前順に並べて続けます(…Person(string name,address wallet))。その keccak256 が型ハッシュです。hashStruct(s) = keccak256(typeHash ‖ encodeData(s)):各メンバーは 32 バイトにエンコードされます。整数とアドレスは ABI ワードとして、stringとbytesはその内容の keccak256 として、ネストした構造体はそれ自身のhashStructとして、配列は各要素のエンコーディングを連結したものの keccak256 としてエンコードされます。domainSeparator = hashStruct(EIP712Domain):name、version、chainId、verifyingContract、saltのうち、アプリケーションが使うものを、この順に並べて計算します。
署名されるダイジェストは keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)) です。先頭の 0x19 があるため、これが有効なトランザクションになることは決してありません(RLP が 0x19 で始まることはないため)。また、バージョンバイト 0x01 によって、EIP-191 の personal_sign が使う 0x19 "Ethereum Signed Message" 形式と区別されます。署名は通常の secp256k1 の ECDSA 署名で、リカバリ用のバイトを含めて 65 バイトあり、署名者のアドレスはこの署名から復元されます。
仕様書自体に載っている例を、公開データ(型付きデータと公開済みの v、r、s)だけを使って viem で再計算したものです。
// The example from the EIP-712 specification (assets/eip-712/Example.js), checked with viem.
// Only public data is used: the typed data and the published signature (v, r, s).
import { hashDomain, hashStruct, hashTypedData, keccak256, recoverTypedDataAddress, serializeSignature, toHex } from "viem";
const types = {
Person: [
{ name: "name", type: "string" },
{ name: "wallet", type: "address" },
],
Mail: [
{ name: "from", type: "Person" },
{ name: "to", type: "Person" },
{ name: "contents", type: "string" },
],
};
const domain = { name: "Ether Mail", version: "1", chainId: 1, verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" };
const message = {
from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" },
to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
contents: "Hello, Bob!",
};
const encodeType = "Mail(Person from,Person to,string contents)Person(string name,address wallet)";
console.log(`typeHash(Mail) ${keccak256(toHex(encodeType))}`);
console.log(`hashStruct(Mail) ${hashStruct({ data: message, primaryType: "Mail", types })}`);
console.log(`domainSeparator ${hashDomain({ domain, types: { EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
] } })}`);
console.log(`digest ${hashTypedData({ domain, types, primaryType: "Mail", message })}`);
const signature = serializeSignature({
r: "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d",
s: "0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562",
v: 28n,
});
console.log(`signer ${await recoverTypedDataAddress({ domain, types, primaryType: "Mail", message, signature })}`);
typeHash(Mail) 0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2
hashStruct(Mail) 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
domainSeparator 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
digest 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer 0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826
すべての値が EIP の Example.js に記載された期待値と一致し、署名からは例の署名者が復元されます。
ドメインが守るもの、守らないもの
ドメインによって、署名は 1 つのチェーン上の 1 つのコントラクトに対してのみ有効になります。チェーン 1 向けに署名したメッセージは、チェーン 8453 では検証に通りません。また、トークンの version が変わると、未使用の署名はすべて無効になります。仕様では、chainId がアクティブなチェーンと一致しない場合、ウォレットは「署名を拒否すべき」としています。MetaMask は実際に拒否し、Provided chainId "1" must match the active chainId "137" というエラーを返します。
一方、ドメインからは、そのコントラクトが本物かどうかはわかりません。フィッシングサイトは、name: "USD Coin" と自分のコントラクトのアドレスを指定して署名を求めることができます。それを見分けられるのは、信頼できるアドレスと照合した verifyingContract だけです。ERC-2612 に対応したトークンでは verifyingContract はトークンコントラクト自体、Permit2 では Permit2 コントラクト、マーケットプレイスの注文では取引を執行するエクスチェンジコントラクトです。
トークンを動かす形式
ERC-2612 の permit(ERC-2612、Final)は、署名で行う approve です:Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)。署名を持っている人なら誰でも、deadline までにそれを送信できます。すると、トークンは allowance[owner][spender] = value を設定し、所有者の nonce をインクリメントします。標準自体が、deadline は「uint(-1) に設定すれば、事実上期限切れにならない Permit を作れる」と述べており、value を 2^256 − 1 にするのが「無制限」を表す慣例です。署名が使われるまでオンチェーンには何も現れないため、ウォレットの承認一覧にも表示されません。
Uniswap Permit2 は、多数のアプリケーションのトークン承認をまとめて保持する単一のコントラクトで、デプロイされているすべてのチェーンで 0x000000000022D473030F116dDEE9F6B43aC78BA3 にあります。ユーザーはオンチェーンで Permit2 を 1 回承認し、それ以降は、どのアプリケーションも署名によってアクセス権を得ます。
PermitSingle/PermitBatch(AllowanceTransfer)は承認額(allowance)を付与します。内容はPermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)、spender、そして署名自体の期限であるsigDeadlineです。amountが2^160 − 1なら無制限で、送金しても減りません。expirationが2^48 − 1なら期限切れにならず、0は「このブロック内のみ」を意味します。PermitTransferFrom/PermitBatchTransferFrom(SignatureTransfer)は、使い捨ての nonce を使い、deadlineまでに最大amountの送金を 1 回だけ許可します。こちらの方が安全です。後に何も残らないからです。- witness 付きのバリアントは、UniswapX の注文などのアプリケーション固有の構造体を、同じ署名に追加します。
Permit2 のドメインには name: "Permit2"、chainId、verifyingContract があり、version はありません。上記の型文字列は、Permit2 の PermitHash.sol から引用したものです。
署名前にリクエストを読む
次のモジュールは、eth_signTypedData_v4 のリクエストを、誰が何をいつまで使えるのか、何がおかしく見えるのかを述べた文に変換します。ERC-2612 と、最もよく使われる 2 種類の Permit2 メッセージを認識し、それ以外の形式についてはその旨をはっきり示します。
// Explains an eth_signTypedData_v4 request before anyone signs it: domain, digest, and for the
// common approval formats, who may spend what until when.
import { getTypesForEIP712Domain, hashDomain, hashStruct, hashTypedData, maxUint160, maxUint256, maxUint48 } from "viem";
const PERMIT2 = "0x000000000022d473030f116ddee9f6b43ac78ba3"; // Uniswap Permit2, same address on every chain it is deployed to
const amount = (v, max) => (BigInt(v) === max ? "UNLIMITED" : BigInt(v).toString());
const when = (t, now) => {
const s = BigInt(t);
if (s === maxUint256 || s === maxUint48) return "never";
const seconds = Number(s - BigInt(now));
const span = seconds < 2 * 86400 ? `${Math.round(seconds / 60)} minutes` : `${Math.round(seconds / 86400)} days`;
return `${new Date(Number(s) * 1000).toISOString().slice(0, 16)}Z, in ${span}`;
};
export function describe(request, { connectedChainId, now = Math.floor(Date.now() / 1000), trustedSpenders = [] }) {
const { domain, primaryType, message } = request;
const types = Object.fromEntries(Object.entries(request.types).filter(([name]) => name !== "EIP712Domain"));
const lines = [
`domain ${domain.name ?? "-"} v${domain.version ?? "-"}, chain ${domain.chainId}, contract ${domain.verifyingContract}`,
`type ${primaryType}`,
// The two hashes a hardware wallet shows when it cannot decode the message, and the signed digest.
`domain hash ${hashDomain({ domain, types: { EIP712Domain: getTypesForEIP712Domain({ domain }) } })}`,
`message hash ${hashStruct({ data: message, primaryType, types })}`,
`digest ${hashTypedData({ domain, types, primaryType, message })}`,
];
const flags = [];
if (domain.chainId !== undefined && Number(domain.chainId) !== connectedChainId) {
flags.push(`domain is for chain ${domain.chainId}, wallet is on ${connectedChainId}`);
}
const spenderCheck = (spender) => {
if (!trustedSpenders.some((s) => s.toLowerCase() === spender.toLowerCase())) flags.push(`spender ${spender} is not one you expect`);
};
if (primaryType === "Permit" && "spender" in message && "value" in message) {
// ERC-2612: verifyingContract is the token itself.
lines.push(`action token ${domain.verifyingContract} lets ${message.spender} spend ${amount(message.value, maxUint256)}, signature valid until ${when(message.deadline, now)}`);
if (BigInt(message.value) === maxUint256) flags.push("unlimited allowance");
spenderCheck(message.spender);
} else if (primaryType === "PermitSingle" || primaryType === "PermitTransferFrom") {
if (domain.name !== "Permit2" || domain.verifyingContract.toLowerCase() !== PERMIT2) {
flags.push(`claims to be a Permit2 message but the domain is ${domain.name} at ${domain.verifyingContract}`);
}
if (primaryType === "PermitSingle") {
const d = message.details;
lines.push(`action ${message.spender} may pull ${amount(d.amount, maxUint160)} of ${d.token} until ${when(d.expiration, now)}; signature valid until ${when(message.sigDeadline, now)}`);
if (BigInt(d.amount) === maxUint160) flags.push("unlimited allowance");
if (BigInt(d.expiration) === maxUint48) flags.push("allowance never expires");
} else {
lines.push(`action ${message.spender} may transfer ${amount(message.permitted.amount, maxUint256)} of ${message.permitted.token} once; valid until ${when(message.deadline, now)}`);
}
spenderCheck(message.spender);
} else {
lines.push("action not a known approval format: read every field before signing");
}
return [...lines, ...flags.map((f) => `RISK ${f}`)].join("\n");
}
サイトが送ってくる可能性のある 3 つのリクエストを、この実行のために生成した鍵で作成します。ユーザーが一度も見たことのないアドレスに対する、期間 3 年で無制限の USDC の permit、同じアドレスに対する、無制限で期限切れにならない Permit2 の承認額、そしてスワップ画面が実際に必要とする、範囲を限定した Permit2 の承認額です。続いて 1 つ目に署名し、その署名をドメインを変えて試します。
// Lab driver: three signature requests a site might send, explained; then sign one and check domain separation.
import { hashDomain, maxUint160, maxUint256, maxUint48, recoverTypedDataAddress } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { describe } from "./what-am-i-signing.mjs";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const ROUTER = "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af"; // Uniswap Universal Router 2.0 on Ethereum
const UNKNOWN = "0x5e1f0000000000000000000000000000000c0ffe"; // an address the user has never dealt with
const now = Math.floor(Date.now() / 1000);
const owner = privateKeyToAccount(generatePrivateKey()); // fresh key for this run
// The token's EIP-712 domain, checked against the value read from the USDC contract on chain.
const usdcDomain = { name: "USD Coin", version: "2", chainId: 1, verifyingContract: USDC };
console.log(`USDC domain separator matches the contract's DOMAIN_SEPARATOR(): ${
hashDomain({ domain: usdcDomain, types: { EIP712Domain: [
{ name: "name", type: "string" }, { name: "version", type: "string" },
{ name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" },
] } }) === "0x06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335"
}\n`);
const requests = {
"1. ERC-2612 permit": {
domain: usdcDomain,
types: { Permit: [
{ name: "owner", type: "address" }, { name: "spender", type: "address" }, { name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" },
] },
primaryType: "Permit",
message: { owner: owner.address, spender: UNKNOWN, value: maxUint256, nonce: 0n, deadline: BigInt(now + 3 * 365 * 86400) },
},
"2. Permit2 allowance": {
domain: { name: "Permit2", chainId: 1, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3" },
types: {
PermitSingle: [{ name: "details", type: "PermitDetails" }, { name: "spender", type: "address" }, { name: "sigDeadline", type: "uint256" }],
PermitDetails: [{ name: "token", type: "address" }, { name: "amount", type: "uint160" }, { name: "expiration", type: "uint48" }, { name: "nonce", type: "uint48" }],
},
primaryType: "PermitSingle",
message: { details: { token: USDC, amount: maxUint160, expiration: maxUint48, nonce: 0n }, spender: UNKNOWN, sigDeadline: BigInt(now + 1800) },
},
"3. Permit2 swap, as a router sends it": {
domain: { name: "Permit2", chainId: 1, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3" },
types: {
PermitSingle: [{ name: "details", type: "PermitDetails" }, { name: "spender", type: "address" }, { name: "sigDeadline", type: "uint256" }],
PermitDetails: [{ name: "token", type: "address" }, { name: "amount", type: "uint160" }, { name: "expiration", type: "uint48" }, { name: "nonce", type: "uint48" }],
},
primaryType: "PermitSingle",
message: { details: { token: USDC, amount: 250_000_000n, expiration: BigInt(now + 30 * 86400), nonce: 3n }, spender: ROUTER, sigDeadline: BigInt(now + 1800) },
},
};
for (const [label, request] of Object.entries(requests)) {
console.log(`${label}\n${describe(request, { connectedChainId: 1, now, trustedSpenders: [ROUTER] })}\n`);
}
// Sign request 1, then see what the same signature proves under a different domain.
const permit = requests["1. ERC-2612 permit"];
const signature = await owner.signTypedData(permit);
console.log(`signature ${signature.slice(0, 20)}... (${(signature.length - 2) / 2} bytes)`);
for (const [label, domain] of [
["as signed", permit.domain],
["same message, chainId 8453", { ...permit.domain, chainId: 8453 }],
["same message, version \"1\"", { ...permit.domain, version: "1" }],
]) {
const signer = await recoverTypedDataAddress({ ...permit, domain, signature });
console.log(`${label.padEnd(30)} recovers ${signer === owner.address ? "the owner" : `another address, ${signer.slice(0, 10)}...`}`);
}
USDC domain separator matches the contract's DOMAIN_SEPARATOR(): true
1. ERC-2612 permit
domain USD Coin v2, chain 1, contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
type Permit
domain hash 0x06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335
message hash 0xd31ad88aaf46e1d347583c08985fca18fdbd119103263d1d015ca3d30a3518f7
digest 0xbd5b314b039b713da90e540e7f341e381b00ca3b13a268e83697717d653f881e
action token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 lets 0x5e1f0000000000000000000000000000000c0ffe spend UNLIMITED, signature valid until 2029-10-19T13:01Z, in 1095 days
RISK unlimited allowance
RISK spender 0x5e1f0000000000000000000000000000000c0ffe is not one you expect
2. Permit2 allowance
domain Permit2 v-, chain 1, contract 0x000000000022D473030F116dDEE9F6B43aC78BA3
type PermitSingle
domain hash 0x866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28
message hash 0xb8b765bce2e69b8175af5e2dac9689673ce2f98eefb3be07ff08f80471ed8efe
digest 0x8fcfc0fb826d723eacfdbf5d39a4ee0a407bd9b93533c22bae7263eccccab894
action 0x5e1f0000000000000000000000000000000c0ffe may pull UNLIMITED of 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 until never; signature valid until 2026-10-20T13:31Z, in 30 minutes
RISK unlimited allowance
RISK allowance never expires
RISK spender 0x5e1f0000000000000000000000000000000c0ffe is not one you expect
3. Permit2 swap, as a router sends it
domain Permit2 v-, chain 1, contract 0x000000000022D473030F116dDEE9F6B43aC78BA3
type PermitSingle
domain hash 0x866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28
message hash 0x4c4dfafb80b5180321026fdb0db11175968834f982b49f812a02f5e780d17036
digest 0x7399baf230812abc18116a5a75c85e2d1fbf97f6e50e6b053e46f185f897f79f
action 0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af may pull 250000000 of 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 until 2026-11-19T13:01Z, in 30 days; signature valid until 2026-10-20T13:31Z, in 30 minutes
signature 0x163c834f3cae48501f... (65 bytes)
as signed recovers the owner
same message, chainId 8453 recovers another address, 0xB19b7dD9...
same message, version "1" recovers another address, 0xe46542B2...
出力からは、次のことが読み取れます。
- USDC のドメインハッシュは USDC コントラクトから読み取った値と一致し、Permit2 のドメインハッシュも、メインネットの Permit2 の
DOMAIN_SEPARATORと一致します。ドメインハッシュがコントラクトの値と異なるリクエストは、nameが何であれ、別のコントラクトか別のチェーン向けのものです。 - リクエスト 1 と 2 は、所有者が今後保有することになるすべての USDC を引き渡します。相手は未知のアドレスで、一方は 3 年間、もう一方は無期限です。リクエスト 2 の署名期限は 30 分で、安全そうに見えますが、そうではありません。この期限が制限するのは署名を送信できる期間であり、その署名で作られる承認額には終わりがありません。
- リクエスト 3 は正当なスワップの例です:既知のルーター、250 USDC(USDC の小数点以下の桁数は 6 なので、
250000000は 250 USDC に相当)、30 日間。 - 署名はそのドメインに結び付いています。別のチェーン ID やバージョンのもとでは別のアドレスが復元されるため、そこでは何の価値もありません。
ハードウェアウォレットでのブラインド署名
ハードウェアウォレットは、メッセージをデバイスの画面向けにデコードできない場合、内容を表示しないまま署名します(ブラインド署名)。Ledger の Ethereum アプリには、ドメインハッシュとメッセージハッシュだけを表示するモードが以前からあります(app-ethereum PR #327)。完全なクリア署名(clear signing)には、そのドメインに対応する ERC-7730 のディスクリプターが必要で、それがない場合、デバイスはブラインド署名にフォールバックします(Ledger のドキュメント)。上の出力の domain hash と message hash の行が、まさに照合すべき値です。リクエストの JSON から 2 台目のマシンでこれらを計算し、どちらかがデバイスの表示と異なれば署名を拒否してください。
マルチシグのトランザクション:Safe の場合
Safe(旧 Gnosis Safe)のオーナーも、EIP-712 のデータに署名してトランザクションを承認します。署名するのは SafeTx 構造体で、宛先の to、value、data、operation フィールド(0 は call、1 は delegatecall)、ガス関連のパラメーター、nonce を含み、ドメインは chainId とその Safe 自身のアドレスで構成されます(Safe.sol によれば、Safe 1.3.0 以降は EIP712Domain(uint256 chainId,address verifyingContract))。delegatecall は、Safe 自身のストレージと残高を使って別のコントラクトのコードを実行するため、operation: 1 の SafeTx はウォレットのロジックを丸ごと置き換えられます。
2025年2月21日に取引所の Bybit で起きたのが、まさにこれです。Sygnia の調査によると、Safe{Wallet} 自身の Web インフラから配信される JavaScript が改ざんされており、Bybit のコールドウォレットに対してのみ動作するようになっていました。署名者たちはウォームウォレットへの定例の送金を承認しているつもりでしたが、実際に署名したトランザクションは operation = 1 を使って、事前にデプロイされていたコントラクトに実行を委任するものでした。このコントラクトがウォレットの実装を置き換え、攻撃者は追加の署名なしに 400,000 ETH を超える資産を引き出しました(Bybit のタイムライン)。インターフェースは偽りを表示しましたが、ハッシュが偽ることはなかったはずです。
// What a Safe owner signs: SafeTx typed data. One changed field changes the message hash and the
// final hash, while the domain hash stays the same. Addresses are random, generated for the run.
import { hashDomain, hashStruct, hashTypedData, keccak256, parseEther, toHex, zeroAddress } from "viem";
import { generatePrivateKey, privateKeyToAddress } from "viem/accounts";
const SAFE_TX =
"SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)";
console.log(`SafeTx type hash equals SAFE_TX_TYPEHASH in Safe.sol: ${keccak256(toHex(SAFE_TX)) === "0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8"}`);
const types = {
SafeTx: [
{ name: "to", type: "address" }, { name: "value", type: "uint256" }, { name: "data", type: "bytes" },
{ name: "operation", type: "uint8" }, { name: "safeTxGas", type: "uint256" }, { name: "baseGas", type: "uint256" },
{ name: "gasPrice", type: "uint256" }, { name: "gasToken", type: "address" }, { name: "refundReceiver", type: "address" },
{ name: "nonce", type: "uint256" },
],
};
const random = () => privateKeyToAddress(generatePrivateKey());
const safe = random();
const warmWallet = random();
const otherContract = random();
const domain = { chainId: 1, verifyingContract: safe }; // Safe 1.3.0+: EIP712Domain(uint256 chainId,address verifyingContract)
const transfer = {
to: warmWallet, value: parseEther("30000"), data: "0x", operation: 0,
safeTxGas: 0n, baseGas: 0n, gasPrice: 0n, gasToken: zeroAddress, refundReceiver: zeroAddress, nonce: 42n,
};
for (const [label, message] of [
["transfer 30000 ETH (operation 0, call)", transfer],
["same fields, operation 1 (delegatecall)", { ...transfer, operation: 1 }],
["delegatecall to another contract, value 0", { ...transfer, to: otherContract, value: 0n, operation: 1 }],
]) {
console.log(`\n${label}`);
console.log(` domain hash ${hashDomain({ domain, types: { EIP712Domain: [{ name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }] } })}`);
console.log(` message hash ${hashStruct({ data: message, primaryType: "SafeTx", types })}`);
console.log(` safeTxHash ${hashTypedData({ domain, types, primaryType: "SafeTx", message })}`);
}
SafeTx type hash equals SAFE_TX_TYPEHASH in Safe.sol: true
transfer 30000 ETH (operation 0, call)
domain hash 0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
message hash 0x2b9d9e60cc9312044229f6a8483dc86eaf49b8cc03ac37af3a44b1e573d28316
safeTxHash 0x46beeb0e67f4c0c3e3ec76e6ee8feabaad2f5df52ec7afe4bfc192d7592dd99e
same fields, operation 1 (delegatecall)
domain hash 0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
message hash 0xc9e7bcc6b496ee176fc021a8d440e3660fbea74ec2da65038e519b4220d5c8af
safeTxHash 0xaf671685ec110193631edb37aa05c813e9cd9e1a9fad061872d44c10e064d00e
delegatecall to another contract, value 0
domain hash 0x40902b4c26feeff637583796e45fb0895d3dc7c8f6426854e32b0a094b131796
message hash 0xcd1a4444a84bf0867dfe41eec2b961b83e187c8fc8262efa2c92e2f4343c0ba3
safeTxHash 0x7c298ccf189f829006f8503d0be25293e358a45884e08ce576f94d5eae0d0148
ドメインハッシュは 3 つのケースすべてで同一です。ドメインハッシュが示すのは「このチェーン上のこの Safe」ということだけです。operation フィールドを 1 つ切り替えるだけで、メッセージハッシュが変わり、したがって最終的なハッシュも変わります。署名者が、ウォレットの Web インターフェースを実行していないマシンで、意図したトランザクションから safeTxHash を計算し直し、ハードウェアデバイスの表示と比較すれば、まさにこの種のすり替えを見破れます。Safe トランザクションハッシュ計算ツールは、この計算をローカルで行います。
危険な署名は EIP-712 だけではない
ほかに 2 つの署名形式にも、同じように注意を払う必要があります。ウォレットでの表示のされ方が異なるからです。
personal_sign(EIP-191 のバージョン0x45) は、メッセージの前に"\x19Ethereum Signed Message:\n"とメッセージの長さを付加します。これは Sign-In with Ethereum のメッセージのような、人間が読めるテキスト向けの形式です。32 バイトの 16 進文字列にpersonal_signで署名するよう求めるリクエストは、読めないものへの署名を求めていることになります。正当なツールでもこれを行うものがあり、特に、型付きデータに対応していないデバイスでトランザクションハッシュへの署名にフォールバックするマルチシグウォレットがそうです。まさにそのような場合こそ、自分でハッシュを計算し直さなければなりません。- EIP-7702 の認可(EIP-7702、Final)は、EIP-712 ではなく
keccak(0x05 ‖ rlp([chain_id, address, nonce]))に対して署名されます。1 つの認可で、自分のアカウントのコードがaddressを指すポインターに設定され、それ以降はそのコントラクトのコードが自分のアカウントとして実行されます。つまり、1 つのトークンの承認ではなく、アカウント全体の委任です。chain_idが 0 の場合、その認可はすべてのチェーンで有効になります。ウォレットはこれを特別な形で表示することが想定されています。ウォレット自身のアップグレード手順以外で認可への署名を求められたら、すべて悪意のあるものとして扱ってください。EIP-7702 デコーダーでは、認可やタイプ 4 のトランザクションの委任先を確認できます。
署名した承認の取り消し
- 使用済みの ERC-2612 permit は通常の承認額と同じです。トークンで
approve(spender, 0)を実行して取り消します。 - 未使用の ERC-2612 permit は、期限が来るか、所有者の nonce が進むまで有効なままです。同じトークンの permit をどれか 1 つ使うと nonce がインクリメントされ、それより古い未使用の permit は無効になりますが、直接「キャンセル」する方法はありません。
- Permit2 の承認額は、
approve(token, spender, 0, 0)で取り消すか、lockdownでまとめて取り消せます。未使用のPermitSingleの署名はinvalidateNonces(token, spender, newNonce)で、未使用の SignatureTransfer の署名はinvalidateUnorderedNoncesで無効化します。 - Permit2 自体に対する元の承認は、トークン側で取り消すまで残ります。この承認があるからこそ、Permit2 の署名はどれも価値を持つのです。
型付きデータを生成する開発者へ
eth_signTypedData_v4を使ってください。MetaMask が型付きデータ向けに推奨しているバージョンです。データの組み立てにはライブラリを使います。手作業で組み立てた JSON は、ethers の ambiguous primary types or unused types や viem の Invalid primary type といったエラーの原因になります。- ドメインには
chainIdとverifyingContractを含めてください。これらがないと、あるチェーン上のアプリ向けの署名が、同じコントラクトロジックがそれを受け入れるほかのすべてのチェーンでも有効になってしまいます。 - 署名するすべての操作をリプレイ攻撃から守ってください:nonce または使用済み署名の集合と、期限を使います。EIP-712 のセキュリティに関する節は、リプレイ保護がアプリケーションの責任であることを明記しています。
- 操作に必要な分だけを求めてください:この取引の金額と、分単位または日単位の有効期限です。無制限で期限切れにならない承認を求めるインターフェースは、ユーザーにそうした承認へ署名する習慣をつけさせてしまいます。
- 検証は、アドレスを復元してから比較する方法で行ってください。スマートコントラクトウォレットの場合は、代わりに ERC-1271 の
isValidSignatureを使います。サーバー側については Sign-In with Ethereum を参照してください。
出典
- EIP-712:型付き構造化データのハッシュ化と署名とその Example.js
- EIP-191:署名付きデータの標準
- ERC-2612:EIP-20 の署名による承認のための Permit 拡張
- Uniswap Permit2:
PermitHash.sol、AllowanceTransfer.sol、SignatureTransfer.sol、libraries/Allowance.sol - MetaMask:データへの署名
- Ledger:ウォレット向けのクリア署名と ERC-7730 レジストリ
- viem:
hashTypedData、hashDomain、hashStruct、recoverTypedDataAddress