在 EIP-191 与 EIP-712 之间做选择,就是在以太坊为非交易数据定义的两种签名格式中二选一。EIP-191(2016年)定义了 0x19 前缀——任何有效的交易编码都不可能以它开头——并用一个版本字节选择格式;版本 0x45 即 personal_sign,用于人类可读的文本。EIP-712(2017年)是版本 0x01:它把类型化的结构化数据与域分隔符一起计算哈希,域分隔符指明了应用、链和验证合约。因此,EIP-712 是 EIP-191 中的一种具体格式,而不是整个标准的竞争者。
概览
EIP-191 personal_sign(版本 0x45) | EIP-712 类型化数据(版本 0x01) | |
|---|---|---|
| 状态 | 最终版,创建于2016年1月20日 | 最终版,创建于2017年9月12日 |
| 钱包 RPC 方法 | personal_sign | eth_signTypedData_v4 |
| 用 Keccak-256 计算哈希的字节 | 0x19 "Ethereum Signed Message:\n" 长度 消息 | 0x19 0x01 domainSeparator hashStruct(message) |
| 钱包显示的内容 | 文本;如果消息是二进制数据,则显示原始十六进制 | 字段名和字段值,以及域(名称、链、合约) |
| 重放保护 | 没有内置;消息文本必须自带 nonce、域名和链 | 域分隔符,包含 name、version、chainId、verifyingContract、salt |
| 合约端验证 | 重建带前缀的消息,然后调用 ecrecover | 重新计算类型哈希和结构体哈希,然后调用 ecrecover |
| 典型用途 | 以太坊登录(EIP-4361)、地址所有权证明 | Permit(EIP-2612)、Permit2、Safe 交易、交易所订单、治理投票 |
| 签名 | 65 字节的 secp256k1 ECDSA r、s、v | 相同 |
| 主要陷阱 | 盲签十六进制哈希;含义模糊的自由文本 | 看似无害的授权;只能显示哈希的设备 |
各自对什么计算哈希
EIP-191 版本 0x45 在消息前加上 "\x19Ethereum Signed Message:\n" 和用十进制 ASCII 表示的消息长度,然后用 Keccak-256 计算哈希。这个前缀保证被签名的字节不可能是 RLP 编码的交易,因此网站无法把一笔转账伪装成消息,诱骗钱包签名。除此之外它什么也不做:签名的含义是“登录 example.com”还是“批准无限额度的支出”,完全取决于接收方如何解读这段文本。
EIP-712 把每个结构体编码为其类型字符串的哈希,后接各字段对应的 32 字节字:字符串和字节串取哈希值,地址和整数在左侧填充,嵌套结构体则替换为它自身的哈希。域按同样的方式编码为 EIP712Domain 结构体。最终的摘要为 keccak256(0x19 0x01 domainSeparator hashStruct(message))。
下面的脚本在 Node.js 26 上用 @noble/curves 2.3.0 和 @noble/hashes 2.3.0 计算这两种摘要,使用的是 EIP-712 参考实现 Example.js 中的“Ether Mail”消息和签名密钥。这个密钥 keccak256("cow") 作为测试向量公布在规范中:任何人都能推导出它,因此其地址绝不能存放资金。
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { keccak_256 } from "@noble/hashes/sha3.js";
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
const hex = (b) => "0x" + bytesToHex(b);
// Test key published in the EIP-712 reference example (assets/eip-712/Example.js): keccak256("cow").
// It is public, so never send funds to its address.
const secretKey = keccak_256(utf8ToBytes("cow"));
const signer = hex(keccak_256(secp256k1.getPublicKey(secretKey, false).subarray(1)).subarray(12));
const sign = (digest) => {
const sig = secp256k1.sign(digest, secretKey, { prehash: false, format: "recovered" });
return { v: sig[0] + 27, r: hex(sig.subarray(1, 33)), s: hex(sig.subarray(33)) };
};
// EIP-191 version 0x45: personal_sign
const text = utf8ToBytes("hello");
const personalDigest = keccak_256(concatBytes(utf8ToBytes(`\x19Ethereum Signed Message:\n${text.length}`), text));
console.log("EIP-191 personal_sign digest:", hex(personalDigest));
// EIP-712: typed structured data (the "Ether Mail" example from the EIP)
const types = {
EIP712Domain: [["name", "string"], ["version", "string"], ["chainId", "uint256"], ["verifyingContract", "address"]],
Person: [["name", "string"], ["wallet", "address"]],
Mail: [["from", "Person"], ["to", "Person"], ["contents", "string"]],
};
const encodeType = (primary) => {
const deps = new Set();
const walk = (t) => types[t].forEach(([, ft]) => { if (types[ft] && !deps.has(ft)) { deps.add(ft); walk(ft); } });
walk(primary);
return [primary, ...[...deps].sort()].map((t) => `${t}(${types[t].map(([n, ft]) => `${ft} ${n}`).join(",")})`).join("");
};
const encodeValue = (ft, v) =>
types[ft] ? hashStruct(ft, v)
: ft === "string" ? keccak_256(utf8ToBytes(v))
: ft === "address" ? concatBytes(new Uint8Array(12), hexToBytes(v.slice(2)))
: hexToBytes(BigInt(v).toString(16).padStart(64, "0")); // uint256
const hashStruct = (t, v) =>
keccak_256(concatBytes(keccak_256(utf8ToBytes(encodeType(t))), ...types[t].map(([n, ft]) => encodeValue(ft, v[n]))));
const domain = { name: "Ether Mail", version: "1", chainId: 1, verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" };
const mail = {
from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" },
to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
contents: "Hello, Bob!",
};
const typedDigest = (d) => keccak_256(concatBytes(new Uint8Array([0x19, 0x01]), hashStruct("EIP712Domain", d), hashStruct("Mail", mail)));
console.log("encodeType(Mail):", encodeType("Mail"));
console.log("domainSeparator:", hex(hashStruct("EIP712Domain", domain)));
console.log("hashStruct(Mail):", hex(hashStruct("Mail", mail)));
console.log("EIP-712 digest:", hex(typedDigest(domain)));
console.log("signer:", signer, sign(typedDigest(domain)));
console.log("same mail, chainId 10:", hex(typedDigest({ ...domain, chainId: 10 })));
EIP-191 personal_sign digest: 0x50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750
encodeType(Mail): Mail(Person from,Person to,string contents)Person(string name,address wallet)
domainSeparator: 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
hashStruct(Mail): 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
EIP-712 digest: 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer: 0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826 {
v: 28,
r: '0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d',
s: '0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562'
}
same mail, chainId 10: 0x2752db86097e9749484b4645855d9ff3d1b6109c89ed55a9d7f9021dbc77bcf9
类型字符串、域分隔符、结构体哈希、摘要、签名者地址以及 v、r、s,与 EIP-712 参考实现 Example.js 中断言的值完全一致;签名是确定性的(RFC 6979),因此你可以复现。只改动 chainId 就会得到不同的摘要,这正是防止跨链重放的机制。EIP-712 查看器可以为任意类型化数据载荷显示同样的中间哈希。
用户实际看到的内容
EIP-712 的主要好处在于显示:钱包可以显示“spender”“value”和“deadline”,而不是一串不透明的字符串。但前提是设备能渲染这些字段,并且用户真的会去读。如今,大多数盗取资金的钓鱼攻击索要的都是完全有效的 EIP-712 签名,例如向被授权方授予无限额度代币的 permit(EIP-2612)或 Permit2 授权;整个过程没有交易,也不消耗 gas,因此用户看不到任何链上警告。2025年2月,Bybit 的三名签名者在只能显示交易哈希的硬件钱包上批准了一笔恶意的 Safe 交易;这笔交易替换了钱包的实现合约,约 15 亿美元的 ETH 被盗。像 Safe 交易哈希工具那样独立地重新计算 Safe 交易哈希,签名者就能核对设备以十六进制显示的内容。
personal_sign 的弱点恰好相反:钱包会如实显示文本,但网站可以让用户签署一个 32 字节的十六进制字符串,而这样的字符串什么也看不出来。正因如此,不带任何前缀的原始哈希签名(eth_sign)在2023年被 MetaMask 默认禁用,并于2024年被移除。
验证签名
对于外部拥有账户(EOA),两种格式的验证方式相同:重新计算摘要,用 ecrecover(或库中的等效函数)恢复签名者,然后比较地址;拒绝 high-s 签名,以避免延展性问题。智能合约钱包(Safe、ERC-4337 账户)无法自行生成 ECDSA 签名,因此验证者必须调用 ERC-1271 的 isValidSignature(hash, signature),并接受魔术值 0x1626ba7e;ERC-6492 将这一机制扩展到尚未部署的钱包。以太坊签名验证工具和 SIWE 验证工具可以处理这两种情况。
如何选择
- 登录和地址所有权证明:EIP-191,配合包含域名、URI、链 ID、nonce 和过期时间的 EIP-4361(SIWE)消息。
- 无需交易的代币授权:EIP-712 permit 或 Permit2,并设置较短的截止时间。
- 订单、投票、元交易、证明:EIP-712,域中包含
chainId和verifyingContract。 - 多签交易:EIP-712(Safe 即采用此方式),并在第二台设备上独立验证哈希。
- 需要人阅读的自由格式文本:EIP-191。
- 切勿使用:对哈希执行
personal_sign,或不含链 ID 的 EIP-712 域。
迁移说明
- 把链下授权从
personal_sign迁移到 EIP-712:定义结构体,加入 nonce 和截止时间字段,旧格式只接受到已发出的签名全部过期为止。 - 为域设定版本:字段语义变化时递增
version,使旧签名无法按新规则解读。 - 验证时使用与签名相同的库,并用上文的 EIP-712 参考测试向量做测试。
- 通过 ERC-1271 和 ERC-6492 支持合约钱包,而不是默认只用
ecrecover。 - 可撤销、会过期:为用户提供撤销 permit 的途径,并使用较短的截止时间;没有过期时间的签名会永久有效。