EIP-712 结构化数据签名
一、为什么需要 EIP-712?
传统签名的问题
在第 07 章我们学过用 eth_sign 签名。但用户在钱包中看到的是一串不可读的哈希值:
MetaMask 弹窗:
"您正在签名以下消息:"
0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
用户: "这是什么??我签的是转账还是授权?完全看不懂!"
这会导致:
- 用户不知道自己签了什么(可能被钓鱼)
- 恶意 DApp 可以诱骗用户签名(用户以为签的是登录,实际是授权转账)
EIP-712 的解决方案
EIP-712 定义了一种结构化数据签名标准,让钱包能以可读的形式展示签名内容:
MetaMask 弹窗(EIP-712):
┌─────────────────────────────────┐
│ EIP712Storage 请求签名 │
│ │
│ Domain: │
│ Name: EIP712Storage │
│ Version: 1 │
│ Chain ID: 11155111 │
│ Contract: 0x1234...5678 │
│ │
│ Storage: │
│ Spender: 0xABCD...EF01 │
│ Number: 42 │
│ │
│ [签名] [拒绝] │
└─────────────────────────────────┘
用户: "哦!我是在授权 0xABCD 把 number 设为 42,确认!"
二、EIP-712 的核心结构
EIP-712 的签名消息由三部分组成:
签名数据 = "\x19\x01" + DOMAIN_SEPARATOR + structHash
其中:
\x19\x01 → EIP-712 固定前缀(区别于其他签名标准)
DOMAIN_SEPARATOR → 域分隔符(唯一标识这个合约)
structHash → 结构化数据的哈希(具体的业务内容)
2.1 Domain Separator(域分隔符)
防止一个合约的签名被拿到另一个合约使用:
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("EIP712Storage")), // 合约名称
keccak256(bytes("1")), // 版本号
block.chainid, // 链 ID
address(this) // 合约地址
));
| 字段 | 作用 |
|---|---|
| name | 合约/应用名称,让用户知道是哪个应用请求签名 |
| version | 版本号,升级后旧签名失效 |
| chainId | 防跨链重放 |
| verifyingContract | 防跨合约重放 |
2.2 TypeHash(类型哈希)
定义结构化数据的”模板”:
// 定义数据结构的 TypeHash
bytes32 constant STORAGE_TYPEHASH = keccak256(
"Storage(address spender,uint256 number)"
);
2.3 StructHash(结构哈希)
将具体的数据填入模板并哈希:
bytes32 structHash = keccak256(abi.encode(
STORAGE_TYPEHASH,
msg.sender, // spender: 谁在操作
_num // number: 要存储的值
));
三、完整的签名与验证流程
┌───────────────────────────────────────────────────┐
│ 链下(前端/钱包) │
├───────────────────────────────────────────────────┤
│ 1. 构造 EIP-712 签名请求(包含 domain + message) │
│ 2. 调用 eth_signTypedData_v4 方法 │
│ 3. 钱包弹窗展示结构化数据,用户确认 │
│ 4. 钱包用私钥签名,返回 signature (v, r, s) │
└───────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────┐
│ 链上(智能合约) │
├───────────────────────────────────────────────────┤
│ 1. 重新构造 digest: │
│ digest = keccak256("\x19\x01" + DOMAIN + structHash) │
│ 2. ecrecover(digest, v, r, s) → 恢复签名者地址 │
│ 3. 验证签名者是否为 owner │
│ 4. 通过 → 执行操作 │
└───────────────────────────────────────────────────┘
四、代码详解
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract EIP712Storage {
using ECDSA for bytes32;
// TypeHash: 定义 Domain 的结构
bytes32 private constant EIP712DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
// TypeHash: 定义业务数据的结构
bytes32 private constant STORAGE_TYPEHASH = keccak256(
"Storage(address spender,uint256 number)"
);
// Domain Separator: 部署时计算,唯一标识此合约
bytes32 private DOMAIN_SEPARATOR;
uint256 number; // 要存储的值
address owner; // 有权签名的地址
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
EIP712DOMAIN_TYPEHASH,
keccak256(bytes("EIP712Storage")), // name
keccak256(bytes("1")), // version
block.chainid, // chainId
address(this) // verifyingContract
));
owner = msg.sender;
}
/// @notice 通过 EIP-712 签名来修改 number(无需 owner 直接调用)
function permitStore(uint256 _num, bytes memory _signature) public {
require(_signature.length == 65, "Invalid signature length");
// 提取 r, s, v
bytes32 r; bytes32 s; uint8 v;
assembly {
r := mload(add(_signature, 0x20))
s := mload(add(_signature, 0x40))
v := byte(0, mload(add(_signature, 0x60)))
}
// 构造 EIP-712 digest
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01", // EIP-712 前缀
DOMAIN_SEPARATOR, // 域分隔符
keccak256(abi.encode( // structHash
STORAGE_TYPEHASH,
msg.sender, // spender
_num // number
))
));
// 从签名恢复签名者地址
address signer = digest.recover(v, r, s);
// 验证签名者是 owner
require(signer == owner, "Invalid signature");
// 签名有效,修改状态
number = _num;
}
function retrieve() public view returns (uint256) {
return number;
}
}
五、前端签名代码(ethers.js)
const { ethers } = require("ethers");
async function signEIP712() {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
// 定义 Domain(与合约中一致)
const domain = {
name: "EIP712Storage",
version: "1",
chainId: 11155111, // Sepolia
verifyingContract: "0x合约地址"
};
// 定义类型
const types = {
Storage: [
{ name: "spender", type: "address" },
{ name: "number", type: "uint256" }
]
};
// 定义具体数据
const message = {
spender: await signer.getAddress(),
number: 42
};
// 签名!钱包会弹窗展示结构化数据
const signature = await signer.signTypedData(domain, types, message);
console.log("签名:", signature);
// 将 signature 发送给合约的 permitStore 函数
}
六、EIP-712 vs 普通签名 对比
| 对比项 | eth_sign(普通签名) | EIP-712(结构化签名) |
|---|---|---|
| 用户看到的 | 不可读的哈希值 | 结构化的可读数据 |
| 安全性 | 容易被钓鱼 | 用户能看懂签名内容 |
| 前缀 | \x19Ethereum Signed Message:\n32 | \x19\x01 |
| 跨合约保护 | 无 | Domain Separator |
| 标准化程度 | 基础 | 高度标准化 |
| 钱包支持 | 所有钱包 | 主流钱包(MetaMask等) |
七、\x19 前缀家族
| 前缀 | 标准 | 用途 |
|---|---|---|
\x19Ethereum Signed Message:\n | EIP-191 | 普通消息签名 |
\x19\x01 | EIP-712 | 结构化数据签名 |
\x19\x00 | EIP-191 | 带验证者的数据 |
八、总结
EIP-712 的核心价值:
- 可读性:用户在钱包中能看懂签名内容
- 安全性:Domain Separator 防止跨合约/跨链重放
- 标准化:所有 DApp 使用统一格式,钱包统一展示
- 基石作用:ERC-2612(Permit)、ERC-4337(账户抽象)等标准都建立在 EIP-712 之上