跳到主要内容
ecdsa.com
← 全部文章

EIP-712:你实际签署的是什么,又该如何解读

类型化数据签名不消耗 Gas,在被人使用之前也不会出现在任何区块浏览器中,却可能把某一种代币的全部余额交出去长达数年。本文讲解这些字节是什么、常见的授权格式授予了什么权限,并给出一个简短的程序,在任何人签名之前把签名请求转换成一段直白的说明。

简短回答:EIP-712 签名的是结构化数据的哈希:keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message))。域(名称、版本、链 ID、验证合约)说明签名是给哪条链上的哪个合约用的;消息是一个带类型的结构体。签名之前要看四样东西:verifyingContract 和 chainId(这是你以为的那个合约吗?是你当前所在的链吗?)、primaryType(是授权、订单,还是登录?)、被授权方或收款方,以及金额和截止时间。如果 ERC-2612 Permit 或 Uniswap Permit2 消息的金额为最大值、截止时间又很遥远,它就是一个无限授权,任何持有该签名的人日后都能激活。如果你的硬件钱包只显示域哈希和消息哈希,就在另一台设备上重新计算这两个值并进行比对。

这些字节是什么

EIP-712(已定稿)定义了三个部分:

  • encodeType:把结构体类型写成字符串 Mail(Person from,Person to,string contents),后面按名称排序接上它引用的每个结构体类型:…Person(string name,address wallet)。它的 keccak256 就是类型哈希(type hash)。
  • 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 字节,签名者的地址就是从签名中恢复出来的。

下面用 viem 重新计算规范自带的示例,只用到公开数据(类型化数据和公布的 v、r、s):

spec-vector.mjs(viem 2.57.7,Node.js 24.22)
// 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 })}`);
输出(node:24.22.0-bookworm-slim)
typeHash(Mail)     0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2
hashStruct(Mail)   0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
domainSeparator    0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
digest             0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer             0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826

每个值都与该 EIP 的 Example.js 中的预期一致,签名也恢复出了示例中的签名者。

域能保护什么,不能保护什么

域让签名只对一条链上的一个合约有效。为链 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,已定稿)是通过签名完成的 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 授权一次;此后,每个应用都通过签名获得访问权限:

  • PermitSingle / PermitBatch(AllowanceTransfer)授予授权额度:PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)、一个 spender,以及签名本身的 sigDeadline。amount 取 2^160 − 1 表示无限额度,转账也不会使其减少;expiration 取 2^48 − 1 表示永不过期,取 0 则表示“仅在当前区块内有效”。
  • PermitTransferFrom / PermitBatchTransferFrom(SignatureTransfer)授权在 deadline 之前进行一次不超过 amount 的转账,并使用一次性 nonce。这类签名更安全:不会留下任何长期有效的授权。
  • witness(见证)变体会把应用自己的结构体(例如 UniswapX 订单)加入同一个签名。

Permit2 的域包含 name: "Permit2"、chainId 和 verifyingContract,没有 version。上面的类型字符串取自 Permit2 的 PermitHash.sol。

签名前解读签名请求

下面这个模块把 eth_signTypedData_v4 请求转换成一段说明:谁可以花费什么、期限到何时,以及哪里看起来不对。它能识别 ERC-2612 和两种最常见的 Permit2 消息,遇到其他格式时会直接说明无法识别。

what-am-i-signing.mjs(viem 2.57.7)
// 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");
}

下面是网站可能发来的三个请求,使用为本次运行生成的密钥构造:一个给用户从未见过的地址、为期三年的无限额 USDC Permit;一个给同一地址、永不过期的无限 Permit2 授权额度;以及兑换界面真正需要的、有上限的 Permit2 授权额度。然后对第一个请求签名,并在修改过的域下检验这个签名:

run.mjs
// 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)}...`}`);
}
输出(node:24.22.0-bookworm-slim,2026年10月20日)
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 都交给了一个未知地址:一个为期三年,一个永久有效。请求 2 的签名截止时间是 30 分钟,看起来安全,其实不然:截止时间限制的只是签名可以被提交的时间,而它创建的授权额度没有尽头。
  • 请求 3 才是正常兑换应有的样子:已知的路由合约、250 USDC(USDC 有 6 位小数,所以 250000000 就是 250 个单位)、30 天。
  • 签名与它的域绑定。换成另一个链 ID 或版本,它恢复出的是另一个地址,因此在那里毫无用处。

硬件钱包上的盲签

如果硬件钱包无法把消息解码后显示在设备屏幕上,它就会盲签。Ledger 的 Ethereum 应用早就有一种只显示域哈希和消息哈希的模式(app-ethereum PR #327);完整的清晰签名(clear signing)需要该域的 ERC-7730 描述文件,没有它,设备就会退回盲签(Ledger 文档)。上面输出中的 domain hash 和 message hash 两行正是要比对的值:在另一台机器上根据请求的 JSON 计算这两个值,只要其中任何一个与设备上显示的不同,就拒绝签名。

多签交易:以 Safe 为例

Safe(前身为 Gnosis Safe)的所有者同样通过签署 EIP-712 数据来批准交易:一个 SafeTx 结构体,包含目标 to、value、data、operation 字段(0 表示 call,1 表示 delegatecall)、Gas 参数和 nonce,其域由 chainId 和该 Safe 自己的地址组成(根据 Safe.sol,自 Safe 1.3.0 起为 EIP712Domain(uint256 chainId,address verifyingContract))。delegatecall 会用 Safe 自己的存储和余额运行另一个合约的代码,因此 operation: 1 的 SafeTx 可以彻底替换钱包的逻辑。

交易所 Bybit 在2025年2月21日遭遇的正是这种情况。根据 Sygnia 的调查,由 Safe{Wallet} 自己的 Web 基础设施提供的 JavaScript 被篡改,只在 Bybit 的冷钱包上触发。签名者以为自己批准的是一笔转到温钱包的常规转账;而他们实际签署的交易使用 operation = 1,把执行委托给一个预先部署的合约,该合约替换了钱包的实现,让攻击者无需再获得任何签名就转走了超过 400,000 ETH(Bybit 的时间线)。界面撒了谎,而哈希本不会。

safe-tx.mjs
// 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 })}`);
}
输出(node:24.22.0-bookworm-slim)
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

三种情况下的域哈希完全相同:它只表示“这条链上的这个 Safe”。只改动 operation 这一个字段,消息哈希就会改变,最终哈希也随之改变。签名者如果在一台不运行该钱包 Web 界面的机器上,根据自己打算发起的交易重新计算 safeTxHash,并与硬件设备上显示的值比对,就恰好能识破这类掉包。Safe 交易哈希工具会在本地完成这项计算。

危险的签名不只有 EIP-712

另外两种签名格式同样值得仔细审查,因为钱包显示它们的方式不同:

  • personal_sign(EIP-191 版本 0x45)会在消息前加上 "\x19Ethereum Signed Message:\n" 和消息长度。它面向人类可读的文本,例如 Sign-In with Ethereum 消息。如果一个请求要你用 personal_sign 签署一个 32 字节的十六进制字符串,那就是要你签署你看不懂的东西。一些正规工具也会这样做,尤其是多签钱包:在不支持类型化数据的设备上,它们会退而对交易哈希签名——而这恰恰是你必须自己重新计算哈希的时候。
  • EIP-7702 授权(EIP-7702,已定稿)签名的对象是 keccak(0x05 ‖ rlp([chain_id, address, nonce])),而不是 EIP-712 数据。一条授权就会把你账户的代码设置为指向 address 的指针,此后那个合约的代码就以你的账户身份运行:这是对整个账户的委托,而不是对某一种代币的授权。chain_id 为 0 时,这条授权在所有链上都有效。钱包理应以特殊方式显示这类授权;凡是在钱包自身升级流程之外要求你签署授权的请求,都应视为恶意请求。EIP-7702 解码器会显示一条授权或一笔类型 4 交易委托给了谁。

撤销已签署的授权

  • 已使用的 ERC-2612 Permit 就是普通的授权额度:在代币合约上调用 approve(spender, 0) 撤销即可。
  • 未使用的 ERC-2612 Permit 在截止时间之前、或在所有者的 nonce 变化之前一直有效。使用同一代币的任意一个 Permit 都会使 nonce 加一,让更早的未使用 Permit 作废,但并没有直接的“取消”操作。
  • Permit2 授权额度可以用 approve(token, spender, 0, 0) 撤销,或用 lockdown 批量撤销;未使用的 PermitSingle 签名可以用 invalidateNonces(token, spender, newNonce) 作废,未使用的 SignatureTransfer 签名则用 invalidateUnorderedNonces 作废。
  • 对 Permit2 本身的底层授权在代币合约上撤销之前一直有效;正是这项授权让每一个 Permit2 签名都具有价值。

给生成类型化数据的开发者

  1. 使用 eth_signTypedData_v4——MetaMask 为类型化数据推荐的版本——并用库来构造请求:手工拼装的 JSON 正是各种错误的来源,例如 ethers 中的 ambiguous primary types or unused types 和 viem 中的 Invalid primary type。
  2. 在域中包含 chainId 和 verifyingContract。缺少它们,你的应用在一条链上的签名,在其他任何由相同合约逻辑接受它的链上也都有效。
  3. 让每个经过签名的操作都能防重放:使用 nonce 或已用签名集合,再加上截止时间。EIP-712 的安全章节明确指出,重放保护是应用自己的职责。
  4. 只请求操作真正需要的权限:本次交易的金额,以分钟或天计的有效期。请求无限额、永不过期授权的界面,会让用户养成签署这类授权的习惯。
  5. 通过恢复来验证,再比对地址;对智能合约钱包则改用 ERC-1271 isValidSignature。服务端的做法见 Sign-In with Ethereum。

参考资料