ERC-2612(Permit):链下授权签名
一、解决什么问题?
传统 ERC20 授权的痛点
在传统 ERC20 中,如果你想让 DEX 花费你的代币,需要两笔交易:
传统流程(2笔交易,2次 Gas):
交易1: 用户调用 token.approve(DEX地址, 金额) ← 花 Gas!
交易2: 用户调用 DEX.swap(...) ← 又花 Gas!
└─ DEX 内部调用 token.transferFrom(用户, DEX, 金额)
问题:
- 多花一笔 Gas:approve 本身就是一笔链上交易
- 体验差:用户要签名确认两次
- 新用户困惑:“为什么要先点一次授权再点一次交换?“
ERC-2612 的解决方案
用 EIP-712 签名替代链上 approve,实现链下授权:
ERC-2612 流程(1笔交易):
链下: 用户用私钥签名 permit(免费!不消耗 Gas)
链上: 用户/第三方调用 token.permit(owner, spender, value, deadline, v, r, s)
└─ 合约验证签名 → 自动执行 approve
└─ 然后继续 DEX.swap(...)
总共只需 1 笔链上交易!
二、核心概念
| 概念 | 说明 |
|---|---|
| permit | ”许可”,用签名代替 approve 进行授权 |
| owner | 代币持有者(签名者) |
| spender | 被授权使用代币的地址 |
| value | 授权金额 |
| deadline | 签名过期时间,超时签名无效 |
| nonce | 每个 owner 的签名计数器,防重放 |
三、完整流程图
┌────────────────────────────────────────────────────────────┐
│ 链下(免 Gas) │
├────────────────────────────────────────────────────────────┤
│ 1. 构造 Permit 数据: │
│ { owner, spender, value, nonce, deadline } │
│ 2. 用 EIP-712 格式签名(钱包弹窗展示可读信息) │
│ 3. 得到 signature (v, r, s) │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 链上(1笔交易) │
├────────────────────────────────────────────────────────────┤
│ 调用 token.permit(owner, spender, value, deadline, v, r, s) │
│ 1. 检查 deadline 未过期 │
│ 2. 构造 EIP-712 digest │
│ 3. ecrecover 验证签名者 == owner │
│ 4. nonce++ 防重放 │
│ 5. _approve(owner, spender, value) 完成授权 │
│ │
│ 接着调用 transferFrom 使用代币 │
└────────────────────────────────────────────────────────────┘
四、接口定义(IERC20Permit)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IERC20Permit {
/// @notice 通过签名授权 spender 使用 owner 的代币
/// @param owner 代币持有者
/// @param spender 被授权者
/// @param value 授权金额
/// @param deadline 签名过期时间戳
/// @param v, r, s ECDSA 签名分量
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
/// @notice 获取 owner 当前的 nonce(防重放)
function nonces(address owner) external view returns (uint256);
/// @notice 获取 EIP-712 域分隔符
function DOMAIN_SEPARATOR() external view returns (bytes32);
}
五、合约实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./IERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract ERC20Permit is ERC20, IERC20Permit, EIP712 {
// 每个地址的 nonce(防重放攻击)
mapping(address => uint256) public _nonces;
// Permit 的 TypeHash(定义签名数据结构)
bytes32 private constant _PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
/// @notice 构造函数,初始化 ERC20 和 EIP-712 Domain
constructor(
string memory name,
string memory symbol
) EIP712(name, "1") ERC20(name, symbol) {}
/// @notice 核心函数:用签名完成授权
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual override {
// 1. 检查签名是否过期
require(block.timestamp <= deadline, "ERC20Permit: expired deadline");
// 2. 构造 structHash(使用当前 nonce 并递增)
bytes32 structHash = keccak256(abi.encode(
_PERMIT_TYPEHASH,
owner,
spender,
value,
_useNonce(owner), // 获取当前 nonce 并 +1
deadline
));
// 3. 构造 EIP-712 完整的 digest
// _hashTypedDataV4 内部会拼接 "\x19\x01" + DOMAIN_SEPARATOR + structHash
bytes32 hash = _hashTypedDataV4(structHash);
// 4. 从签名恢复签名者地址
address signer = ECDSA.recover(hash, v, r, s);
// 5. 验证签名者是否为 owner
require(signer == owner, "ERC20Permit: invalid signature");
// 6. 签名有效!执行授权
_approve(owner, spender, value);
}
/// @notice 获取指定地址的当前 nonce
function nonces(address owner) public view virtual override returns (uint256) {
return _nonces[owner];
}
/// @notice 获取 Domain Separator(继承自 EIP712)
function DOMAIN_SEPARATOR() external view returns (bytes32) {
return _domainSeparatorV4();
}
/// @notice 获取当前 nonce 并递增(内部使用)
function _useNonce(address owner) public virtual returns (uint256 current) {
current = _nonces[owner];
_nonces[owner] += 1;
}
}
六、前端签名代码(ethers.js)
const { ethers } = require("ethers");
async function signPermit() {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const ownerAddress = await signer.getAddress();
const tokenAddress = "0x代币合约地址";
const spenderAddress = "0xDEX合约地址";
const value = ethers.parseEther("100"); // 授权 100 个代币
const nonce = 0; // 从合约读取当前 nonce
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1小时后过期
// EIP-712 Domain(与合约部署时一致)
const domain = {
name: "MyToken",
version: "1",
chainId: 11155111,
verifyingContract: tokenAddress
};
// 类型定义
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" }
]
};
// 具体数据
const message = {
owner: ownerAddress,
spender: spenderAddress,
value: value,
nonce: nonce,
deadline: deadline
};
// 签名(钱包弹窗展示可读内容)
const signature = await signer.signTypedData(domain, types, message);
// 拆分签名为 v, r, s
const sig = ethers.Signature.from(signature);
console.log("v:", sig.v, "r:", sig.r, "s:", sig.s);
// 调用合约 permit
const token = new ethers.Contract(tokenAddress, abi, provider);
await token.permit(
ownerAddress, spenderAddress, value, deadline,
sig.v, sig.r, sig.s
);
}
七、实际使用场景
场景:DEX 一步完成授权+交换
contract DEX {
IERC20Permit public token;
/// @notice 用户一笔交易完成授权+交换
function swapWithPermit(
uint256 amount,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
// 1. 用签名完成授权(替代了用户单独调用 approve)
token.permit(msg.sender, address(this), amount, deadline, v, r, s);
// 2. 使用授权的代币进行交换
token.transferFrom(msg.sender, address(this), amount);
// 3. 执行交换逻辑...
}
}
用户体验对比
传统方式:
用户 → 钱包弹窗1 "确认 approve" → 等待上链 → 钱包弹窗2 "确认 swap" → 等待上链
共: 2 次确认,2 笔 Gas,等待 2 次
Permit 方式:
用户 → 钱包弹窗1 "签名授权"(免费!)→ 钱包弹窗2 "确认 swap"(包含 permit)→ 等待上链
共: 2 次确认,1 笔 Gas,等待 1 次
八、安全机制
8.1 Deadline(过期时间)
签名时设置 deadline = 当前时间 + 1小时
1小时内: permit 正常执行
1小时后: require(block.timestamp <= deadline) 失败 → revert
防止: 签名被长期持有,在用户不知情时被使用
8.2 Nonce(防重放)
用户当前 nonce = 5
签名内容包含 nonce=5 → 合约验证通过 → nonce 变为 6
再次提交同一签名 → 合约计算用 nonce=6 → digest 不同 → 签名无效 ✅
8.3 EIP-712 Domain(防跨合约/跨链)
签名中包含合约地址和链 ID,A 合约的签名无法在 B 合约使用。
九、ERC-2612 vs 传统 Approve 对比
| 对比项 | 传统 approve | ERC-2612 permit |
|---|---|---|
| 交易次数 | 2 笔(approve + 业务操作) | 1 笔(permit + 业务操作合并) |
| Gas 费用 | 高(2笔链上交易) | 低(签名免费+1笔交易) |
| 用户体验 | 差(需等待 approve 上链) | 好(签名即时完成) |
| 安全性 | approve 设置 MAX 有风险 | deadline 限时 + nonce 防重放 |
| 前端实现 | 简单 | 稍复杂(需实现 EIP-712 签名) |
| 兼容性 | 所有 ERC20 | 仅支持 ERC-2612 的代币 |
十、知识链路总结
EIP-191(消息签名)
↓ 升级
EIP-712(结构化签名)← 第22章
↓ 应用
ERC-2612(Permit 授权)← 本章
↓ 进一步应用
ERC-4337(账户抽象)、Uniswap Permit2 等
ERC-2612 是 EIP-712 的最经典应用:用结构化签名实现链下授权,省 Gas、体验好、更安全。目前 USDC、DAI、UNI 等主流代币都支持 Permit。