Web3应用中,让用户签署消息来授权操作是常见需求。相比每次操作都发送交易,签名可以大幅降低成本,而且用户体验也更好。但传统的eth_sign方法缺乏结构化,用户很难理解自己到底在签什么。EIP-712就是为了解决这个问题而诞生的。
为什么需要EIP-712
在没有EIP-712之前,以太坊签名是纯粹的字节数据。用户看到的可能是一串十六进制字符串,根本不知道自己签的内容是什么。这带来两个严重问题:
- 签名欺骗 – 恶意网站可以构造看起来无害的消息,实际包含恶意操作
- 用户体验差 – 用户无法验证消息内容,信任成本高
EIP-712引入了类型化结构化数据的概念,让签名内容和签名请求都可以被人类阅读和理解。这不仅提升了安全性,也大大改善了用户体验。

EIP-712的核心概念
理解EIP-712需要掌握几个核心概念:
Domain Separator
域分隔符定义了签名的上下文,防止跨应用重放攻击。它包含:
- name – 应用名称
- version – 签名方案的版本号
- chainId – 链ID,防止跨链攻击
- verifyingContract – 验证签名的合约地址
- salt – 可选的随机盐值
solidity
struct EIP712Domain {
string name;
string version;
uint256 chainId;
address verifyingContract;
bytes32 salt;
}
Message Structure
实际签名的消息是定义好的结构体,每种操作类型对应一个独立的结构:
solidity
struct Person {
string name;
address wallet;
}
struct Mail {
Person from;
Person to;
string contents;
}
contract EIP712Example {
bytes32 constant MAIL_TYPEHASH = keccak256(
"Mail(Person from,Person to,string contents)Person(string name,address wallet)"
);
bytes32 constant PERSON_TYPEHASH = keccak256(
"Person(string name,address wallet)"
);
bytes32 public domainSeparator;
constructor() {
domainSeparator = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)"),
keccak256("Mail"),
keccak256("1"),
block.chainid,
address(this),
bytes32(0) // salt,可以自定义
));
}
}
哈希计算流程
签名的完整哈希计算遵循特定的层次结构:
plaintext
hashStruct(message) = keccak256(typeHash ‖ data)
hash(message) = keccak256(0x1901 ‖ domainSeparator ‖ hashStruct(message))
0x1901是固定的magic bytes,标识这是EIP-712签名。
完整签名验证合约实现
下面是一个完整的EIP-712签名验证合约,支持委托授权模式:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
library ECDSA {
function recover(bytes32 hash, bytes memory signature)
internal
pure
returns (address)
{
require(signature.length == 65, "Invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
return ecrecover(hash, v, r, s);
}
function toEthSignedMessageHash(bytes32 hash)
internal
pure
returns (bytes32)
{
return keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
hash
));
}
}
contract SecureDelegate {
using ECDSA for bytes32;
// 委托授权结构
struct Delegation {
address delegate; // 被委托的地址
uint256 nonce; // 防重放 nonce
uint256 deadline; // 过期时间
}
// 委托类型哈希
bytes32 constant DELEGATION_TYPEHASH = keccak256(
"Delegation(address delegate,uint256 nonce,uint256 deadline)"
);
// 域名分隔符类型哈希
bytes32 constant DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
// 域名配置
string public name;
string public version = "1";
// 委托者的nonce
mapping(address => uint256) public nonces;
// 已使用的签名哈希(用于一次性签名)
mapping(bytes32 => bool) public usedSignatures;
event DelegateUpdated(address indexed delegator, address indexed newDelegate);
event DelegationExecuted(
address indexed delegator,
address indexed delegate,
bytes32 indexed delegationHash
);
constructor(string memory _name) {
name = _name;
}
function getDomainSeparator() public view returns (bytes32) {
return keccak256(abi.encode(
DOMAIN_TYPEHASH,
keccak256(bytes(name)),
keccak256(bytes(version)),
block.chainid,
address(this)
));
}
function _hashDelegation(Delegation memory delegation)
internal
view
returns (bytes32)
{
return keccak256(abi.encode(
DELEGATION_TYPEHASH,
delegation.delegate,
delegation.nonce,
delegation.deadline
));
}
function _hashTypedData(Delegation memory delegation)
internal
view
returns (bytes32)
{
bytes32 hash = _hashDelegation(delegation);
return keccak256(abi.encodePacked(
"\x19\x01",
getDomainSeparator(),
hash
));
}
/**
* @dev 验证签名并返回签名者地址
*/
function verify(
Delegation memory delegation,
bytes memory signature
) public view returns (address) {
require(delegation.deadline >= block.timestamp, "Delegation expired");
bytes32 typedHash = _hashTypedData(delegation);
address signer = typedHash.recover(signature);
require(signer != address(0), "Invalid signature");
require(signer == msg.sender || isAuthorizedDelegate(signer), "Not authorized");
return signer;
}
/**
* @dev 检查签名是否已使用(一次性签名模式)
*/
modifier notUsed(bytes32 hash) {
require(!usedSignatures[hash], "Signature already used");
_;
}
/**
* @dev 执行委托操作(一次性签名模式)
*/
function executeDelegation(
Delegation memory delegation,
bytes memory signature
) external notUsed(_hashTypedData(delegation)) returns (bool) {
bytes32 typedHash = _hashTypedData(delegation);
// 验证签名
address signer = typedHash.recover(signature);
require(signer != address(0), "Invalid signature");
require(delegation.deadline >= block.timestamp, "Delegation expired");
// 标记签名已使用
usedSignatures[typedHash] = true;
// 更新nonce
nonces[signer]++;
emit DelegationExecuted(signer, delegation.delegate, typedHash);
return true;
}
/**
* @dev 批量执行多个委托
*/
function executeBatch(
Delegation[] memory delegations,
bytes[] memory signatures
) external returns (bool[] memory results) {
require(delegations.length == signatures.length, "Length mismatch");
results = new bool[](delegations.length);
for (uint256 i = 0; i < delegations.length; i++) {
bytes32 typedHash = _hashTypedData(delegations[i]);
if (usedSignatures[typedHash]) {
results[i] = false;
continue;
}
address signer = typedHash.recover(signatures[i]);
if (signer == address(0) || delegations[i].deadline < block.timestamp) {
results[i] = false;
continue;
}
usedSignatures[typedHash] = true;
nonces[signer]++;
emit DelegationExecuted(signer, delegations[i].delegate, typedHash);
results[i] = true;
}
}
/**
* @dev 检查地址是否为授权委托者
*/
function isAuthorizedDelegate(address) public pure returns (bool) {
// 可以扩展此逻辑,实现更复杂的授权机制
return true;
}
/**
* @dev 获取当前nonce
*/
function getNonce(address account) public view returns (uint256) {
return nonces[account];
}
}
这个合约展示了EIP-712签名的核心验证逻辑,包括域分隔符计算、类型哈希、签名恢复等关键步骤。
前端签名生成
合约端的验证需要前端生成对应的签名。以下是使用ethers.js v6生成签名的示例:
javascript
import { ethers } from "ethers";
const DOMAIN = {
name: "SecureDelegate",
version: "1",
chainId: 1, // 替换为实际链ID
verifyingContract: "0x1234567890123456789012345678901234567890", // 替换为合约地址
};
const DELEGATION_TYPE = {
Delegation: [
{ name: "delegate", type: "address" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
async function signDelegation(delegator, delegateAddress, deadline, nonce) {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const message = {
delegate: delegateAddress,
nonce: nonce,
deadline: deadline,
};
const signature = await signer.signTypedData(
DOMAIN,
DELEGATION_TYPE,
message
);
return signature;
}
// 使用示例
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1小时后过期
const nonce = await contract.nonces(userAddress);
const signature = await signDelegation(userAddress, delegateAddress, deadline, nonce);
ethers.js会自动处理类型化数据的哈希计算和签名,大大简化了EIP-712的实现复杂度。
安全性注意事项
EIP-712签名虽然比裸字节签名安全得多,但在实现时仍需注意以下几点:
1. 防止签名重放
每次签名应该包含唯一的nonce和合理的过期时间:
solidity
function verifyWithReplayProtection(
Delegation memory delegation,
bytes memory signature
) public view returns (address) {
// 检查nonce是否匹配
require(delegation.nonce == nonces[msg.sender], "Invalid nonce");
// 检查过期时间
require(delegation.deadline >= block.timestamp, "Signature expired");
// ... 其他验证逻辑
}
2. 验证签名者身份
签名恢复可能返回address(0),这是判断签名无效的关键条件:
solidity
address signer = ECDSA.recover(hash, signature);
require(signer != address(0), "ECDSA: invalid signature");
// 不要忘记检查 signer 是否在预期的身份范围内
require(signer == expectedAddress, "Wrong signer");
3. 完整的域分隔符
域名分隔符的每个字段都应该正确设置,特别是chainId:
solidity
// 错误的做法:在测试网上使用硬编码的chainId
bytes32 domainSeparator = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyApp"),
keccak256("1"),
1, // 硬编码的mainnet chainId
address(this)
));
// 正确的做法:使用当前链的chainId
bytes32 domainSeparator = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyApp"),
keccak256("1"),
block.chainid, // 使用动态chainId
address(this)
));
4. 一次性签名 vs 可重用授权
根据业务场景选择合适的签名模式:
solidity
// 一次性签名:每次操作都需要新签名
mapping(bytes32 => bool) public usedSignatures;
function executeOnce(bytes32 hash, bytes memory signature) {
require(!usedSignatures[hash], "Already used");
usedSignatures[hash] = true;
// 执行操作
}
// 可重用授权:设置一个较长的有效期
mapping(address => uint256) public authorizedUntil;
function executeWithAuth(address signer) {
require(authorizedUntil[signer] >= block.timestamp, "Authorization expired");
// 执行操作
}
实际应用场景
EIP-712签名在以下场景特别有用:
- DAO治理投票 – 离线签名投票,减少投票者的Gas成本
- DEX限价单 – 用户签名挂单,匹配引擎执行,无需每次都发交易
- 许可/白名单 – 用户签名加入白名单的操作证明
- 跨链消息 – 在一条链上签名,在另一条链上验证
solidity
contract SimpleOffchainVoting {
struct Vote {
uint256 proposalId;
bool support;
uint256 deadline;
}
bytes32 constant VOTE_TYPEHASH = keccak256(
"Vote(uint256 proposalId,bool support,uint256 deadline)"
);
mapping(uint256 => mapping(address => bool)) public hasVoted;
mapping(uint256 => uint256) public votesFor;
mapping(uint256 => uint256) public votesAgainst;
function castVote(Vote memory vote, bytes memory signature) public {
bytes32 voteHash = keccak256(abi.encode(
VOTE_TYPEHASH,
vote.proposalId,
vote.support,
vote.deadline
));
// 验证签名
address voter = ECDSA.recover(
ECDSA.toEthSignedMessageHash(voteHash),
signature
);
require(voter == msg.sender, "Signature mismatch");
require(!hasVoted[vote.proposalId][voter], "Already voted");
require(vote.deadline >= block.timestamp, "Vote expired");
// 记录投票
hasVoted[vote.proposalId][voter] = true;
if (vote.support) {
votesFor[vote.proposalId]++;
} else {
votesAgainst[vote.proposalId]++;
}
}
}
常见错误与调试
类型字符串不匹配
前端和合约端的类型定义必须完全一致:
javascript
// 前端 - 错误的顺序
const TYPE = {
Person: [
{ name: "wallet", type: "address" }, // 顺序错误
{ name: "name", type: "string" },
]
};
// 合约端
bytes32 constant PERSON_TYPEHASH = keccak256(
"Person(address wallet,string name)" // 必须匹配
);
缺失链ID验证
生产环境中务必验证chainId:
solidity
require(
keccak256(abi.encodePacked(DOMAIN.chainId)) == keccak256(abi.encodePacked(block.chainid)),
"Chain ID mismatch"
);
签名版本不兼容
ethers.js v5和v6的API有所不同:
javascript
// ethers.js v6
const signature = await signer.signTypedData(domain, types, message);
// ethers.js v5
const signature = await provider.getSigner()._signTypedData(domain, types, message);
总结
EIP-712为以太坊签名带来了结构化和可读性,是构建安全DApp的重要工具。实现时需要关注:
- 完整的域名分隔符 – 包含name、version、chainId、verifyingContract
- 正确的类型哈希 – 前后端类型定义必须一致
- 防重放机制 – nonce和deadline是必需的
- 签名者验证 – 验证恢复地址的有效性
通过合理使用EIP-712,可以显著提升应用的安全性和用户体验,同时降低用户的Gas成本。这在高频操作场景(如DAO投票、DEX交易)中尤为重要。
相关资源

发表回复