简短回答: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):
// 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 签名的同一条消息在链 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 消息,遇到其他格式时会直接说明无法识别。
// 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 授权额度。然后对第一个请求签名,并在修改过的域下检验这个签名:
// 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 都交给了一个未知地址:一个为期三年,一个永久有效。请求 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 的时间线)。界面撒了谎,而哈希本不会。
// 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
三种情况下的域哈希完全相同:它只表示“这条链上的这个 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 签名都具有价值。
给生成类型化数据的开发者
- 使用
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