ERC-20 Permit 与 ECDSA 密码学原理
本文深入解析 EIP-712/EIP-2612 Permit 机制的工作原理,以及底层 ECDSA 椭圆曲线签名的密码学基础。
目录
- Permit 机制概述
- EIP-712 结构化签名
- Permit 完整代码实现 ⭐ 开发者必读
- ECDSA 密码学基础
- 椭圆曲线数学
- 签名与验证流程
- ecrecover 原理
- 安全性分析
- 实际应用场景
1. Permit 机制概述
1.1 传统 ERC-20 授权的问题
传统 ERC-20 代币转账需要两笔交易:
1 2
| 交易1: approve(spender, amount) ← 用户付 Gas 交易2: transferFrom(...) ← DApp 调用
|
痛点:
- 用户需要两次确认
- 需要支付两笔 Gas
- 用户体验差
1.2 Permit 解决方案 (EIP-2612)
Permit 允许用户通过离线签名完成授权:
1 2
| 步骤1: 用户离线签名授权信息 ← 免费,无 Gas 步骤2: 任何人提交签名到链上 ← 可由 DApp 代付 Gas
|
优势:
- 单笔交易完成授权+操作
- 支持 Gasless 交易(元交易)
- 更好的用户体验
1.3 Permit 函数签名
1 2 3 4 5 6 7 8 9
| function permit( address owner, // 代币所有者 address spender, // 被授权者 uint256 value, // 授权金额 uint256 deadline, // 签名有效期 uint8 v, // 签名组件 bytes32 r, // 签名组件 bytes32 s // 签名组件 ) external;
|
2. EIP-712 结构化签名
2.1 为什么需要 EIP-712
早期以太坊签名(eth_sign)只能签名原始字节:
1 2 3 4
| 用户在钱包看到: 0x7f5e3b2a1c4d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f...
问题: 用户无法理解自己在签什么!
|
EIP-712 提供结构化、人类可读的签名格式:
flowchart LR
subgraph Wallet["🦊 钱包签名请求"]
direction TB
H["📋 授权请求"]
A["应用: Uniswap"]
B["授权给: 0x1234..."]
C["金额: 100 USDC"]
D["有效期至: 2024-01-01"]
end
style Wallet fill:#f9f9f9,stroke:#333,stroke-width:2px
EIP-712 的优势:
- 可读性:用户可以清楚看到授权的应用名称、被授权地址、金额和有效期
- 安全性:用户能够验证签名内容是否符合预期,避免被钓鱼攻击
- 标准化:钱包可以以统一的方式展示不同 DApp 的签名请求
2.2 EIP-712 签名结构
完整的 EIP-712 签名消息:
message=keccak256("\x19\x01"∥domainSeparator∥structHash)
其中:
\x19\x01 是 EIP-712 固定前缀
domainSeparator 标识签名的应用/合约
structHash 是实际授权内容的哈希
2.3 Domain Separator
Domain Separator 用于唯一标识签名的使用范围:
1 2 3 4 5 6 7
| bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256(bytes(name)), // 应用名称 keccak256(bytes(version)), // 版本号 chainId, // 链 ID address(this) // 合约地址 ));
|
防重放攻击:
| 字段 |
防止的攻击类型 |
name |
不同应用间签名重放 |
version |
合约升级后旧签名重放 |
chainId |
跨链签名重放 (ETH → BSC) |
verifyingContract |
同链不同合约间重放 |
2.4 Struct Hash
对于 Permit,structHash 的构造:
1 2 3 4 5 6 7 8
| bytes32 structHash = keccak256(abi.encode( keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), owner, spender, value, nonce, // 防止同一签名多次使用 deadline ));
|
3. Permit 完整代码实现
⭐ 开发者必读:本节展示 Permit 的完整实现,帮助你理解每一行代码的作用。
3.1 完整的 ERC20Permit 合约
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131
| // SPDX-License-Identifier: MIT pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
/** * @title ERC20Permit 完整实现 * @notice 支持离线签名授权的 ERC20 代币 */ contract ERC20Permit is ERC20 { using ECDSA for bytes32;
// ============ 状态变量 ============
/// @notice 每个地址的 nonce,防止签名重放 mapping(address => uint256) public nonces;
/// @notice EIP-712 Domain Separator(合约部署时计算) bytes32 public immutable DOMAIN_SEPARATOR;
/// @notice Permit 类型哈希(编译时常量) bytes32 public constant PERMIT_TYPEHASH = keccak256( "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" );
// ============ 构造函数 ============
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) { // 计算 Domain Separator DOMAIN_SEPARATOR = keccak256(abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256(bytes(name_)), // 代币名称 keccak256(bytes("1")), // 版本号 block.chainid, // 当前链 ID address(this) // 本合约地址 )); }
// ============ Permit 核心函数 ============
/** * @notice 通过签名授权 spender 使用 owner 的代币 * @param owner_ 代币所有者地址 * @param spender_ 被授权者地址 * @param value_ 授权金额 * @param deadline_ 签名过期时间戳 * @param v_ 签名恢复标识符 * @param r_ 签名 r 值 * @param s_ 签名 s 值 */ function permit( address owner_, address spender_, uint256 value_, uint256 deadline_, uint8 v_, bytes32 r_, bytes32 s_ ) external { // ========== 步骤 1: 检查签名是否过期 ========== require(block.timestamp <= deadline_, "ERC20Permit: expired deadline");
// ========== 步骤 2: 构造 structHash ========== // 将授权信息编码并哈希 bytes32 structHash = keccak256(abi.encode( PERMIT_TYPEHASH, // 类型标识 owner_, // 代币所有者 spender_, // 被授权者 value_, // 授权金额 _useNonce(owner_), // 获取并递增 nonce deadline_ // 过期时间 ));
// ========== 步骤 3: 构造完整的 EIP-712 消息哈希 ========== // message = keccak256("\x19\x01" || DOMAIN_SEPARATOR || structHash) bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", // EIP-712 前缀 DOMAIN_SEPARATOR, // 域分隔符(标识本合约) structHash // 授权内容哈希 ));
// ========== 步骤 4: 从签名恢复签名者地址 ========== // ecrecover 是 EVM 预编译合约,从签名反推公钥/地址 address recoveredAddress = ecrecover(digest, v_, r_, s_);
// ========== 步骤 5: 验证签名者是否为 owner ========== require( recoveredAddress != address(0) && recoveredAddress == owner_, "ERC20Permit: invalid signature" );
// ========== 步骤 6: 执行授权 ========== // 与 approve() 相同的效果 _approve(owner_, spender_, value_); }
// ============ 辅助函数 ============
/** * @notice 获取当前 nonce 并递增 * @dev 每次 permit 调用都会递增 nonce,防止签名重放 */ function _useNonce(address owner_) internal returns (uint256 current) { current = nonces[owner_]; nonces[owner_] = current + 1; }
/** * @notice 查询 EIP-712 域信息(EIP-5267) */ function eip712Domain() external view returns ( bytes1 fields, string memory name, string memory version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] memory extensions ) { return ( hex"0f", // 表示使用了 name, version, chainId, verifyingContract name(), // 代币名称 "1", // 版本 block.chainid, // 链 ID address(this), // 合约地址 bytes32(0), // salt(未使用) new uint256[](0) // extensions(未使用) ); } }
|
3.2 关键步骤图解
flowchart LR
A["1.检查deadline"] --> B["2.structHash"] --> C["3.digest"] --> D["4.ecrecover"] --> E["5.验证地址"] --> F["6._approve()"]
permit() 执行流程:
- 检查过期 →
require(block.timestamp <= deadline_) — 签名已过期则 revert
- 构造 structHash → 将
(TYPEHASH, owner, spender, value, nonce, deadline) 编码后哈希
- 构造 digest →
keccak256("\x19\x01" || DOMAIN_SEPARATOR || structHash) — 这就是用户签名的内容
- 恢复地址 →
ecrecover(digest, v, r, s) — 通过椭圆曲线数学从签名反推公钥
- 验证身份 →
require(recoveredAddress == owner_) — 只有正确私钥才能恢复出正确地址
- 执行授权 →
_approve(owner_, spender_, value_) — 与 approve() 效果相同
| 步骤 |
作用 |
安全保障 |
| 步骤 1 |
时效控制 |
防止使用过期签名 |
| 步骤 2-3 |
消息构造 |
DOMAIN_SEPARATOR 确保签名只能在本合约使用 |
| 步骤 4-5 |
身份验证 |
ecrecover 验证只有 owner 私钥才能产生有效签名 |
| 步骤 2 |
防重放 |
nonce 递增确保每个签名只能使用一次 |
3.3 前端签名代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
| import { ethers } from "ethers";
async function signPermit( signer: ethers.Signer, tokenAddress: string, tokenName: string, spender: string, value: ethers.BigNumber, nonce: number, deadline: number ): Promise<{ v: number; r: string; s: string }> { const chainId = await signer.getChainId(); const ownerAddress = await signer.getAddress();
const domain = { name: tokenName, version: "1", chainId: chainId, 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: spender, value: value, nonce: nonce, deadline: deadline, };
const signature = await signer._signTypedData(domain, types, message);
const { v, r, s } = ethers.utils.splitSignature(signature);
return { v, r, s }; }
async function main() { const provider = new ethers.providers.Web3Provider(window.ethereum); const signer = provider.getSigner();
const tokenAddress = "0x..."; const spenderAddress = "0x..."; const amount = ethers.utils.parseUnits("100", 6); const deadline = Math.floor(Date.now() / 1000) + 3600;
const tokenContract = new ethers.Contract( tokenAddress, ["function nonces(address) view returns (uint256)"], provider ); const nonce = await tokenContract.nonces(await signer.getAddress());
const { v, r, s } = await signPermit( signer, tokenAddress, "USD Coin", spenderAddress, amount, nonce.toNumber(), deadline );
console.log("签名结果:", { v, r, s });
}
|
3.4 完整交互流程
下图展示了 Permit 从签名到授权的完整流程:
sequenceDiagram
participant User as 用户(前端)
participant Wallet as 钱包
participant Contract as 合约(链上)
Note over User,Contract: Permit 完整流程
User->>Contract: 1. 获取 nonce
Contract-->>User: nonce = 5
Note over User,Wallet: 2. 构造 EIP-712 消息
User->>User: domain = {name, version, chainId, contract}
User->>User: message = {owner, spender, value, nonce=5, deadline}
Note over User,Wallet: 3. 钱包签名
User->>Wallet: 请求签名
Wallet->>Wallet: digest = hash(domain + message)
Wallet->>Wallet: (v, r, s) = sign(digest)
Wallet-->>User: 返回 (v, r, s)
User->>Contract: 4. 调用 permit(owner, spender, value, deadline, v, r, s)
Note over Contract: 5. 合约验证
Contract->>Contract: 检查 deadline
Contract->>Contract: 重建 digest
Contract->>Contract: ecrecover 恢复地址
Contract->>Contract: 验证地址 == owner
Contract->>Contract: nonce++(防重放)
Contract->>Contract: _approve()
Contract-->>User: 授权成功!
Note over User,Contract: 6. 后续操作(如 DEX 交易)
User->>Contract: transferFrom(owner, to, value)
流程要点说明:
| 步骤 |
执行者 |
说明 |
| 1. 获取 nonce |
前端 → 合约 |
每个地址的 nonce 唯一且递增,防止签名被重复使用 |
| 2. 构造消息 |
前端 |
按 EIP-712 格式组装 domain 和 message,确保格式与合约一致 |
| 3. 钱包签名 |
钱包 |
用户私钥对 digest 签名,产生 (v, r, s),此步骤无 Gas |
| 4. 提交签名 |
前端 → 合约 |
将签名和参数提交到链上,可由用户或中继者支付 Gas |
| 5. 合约验证 |
合约 |
重建 digest 并用 ecrecover 验证签名者身份 |
| 6. 后续操作 |
任何人 |
授权完成后,spender 可以调用 transferFrom |
3.5 为什么签名能证明身份?
这是理解 Permit 的关键。签名与消息之间存在密码学绑定关系:
flowchart TD
subgraph SignProcess["签名过程"]
A1["用户用私钥签名 digest"] --> A2["产生签名 (v, r, s)"]
end
subgraph VerifyProcess["验证过程"]
B1["合约用 ecrecover(digest, v, r, s)"] --> B2["反推出地址"]
B2 --> B3{"地址 == owner?"}
B3 -->|是| B4["✅ 验证成功"]
B3 -->|否| B5["❌ 验证失败"]
end
A2 --> B1
subgraph AttackScenarios["攻击场景分析"]
C1["篡改 value(金额)"] --> C2["digest 变了"]
C3["篡改 spender(授权给谁)"] --> C2
C4["篡改 deadline"] --> C2
C5["重放到其他合约"] --> C6["DOMAIN_SEPARATOR 不同"]
C6 --> C2
C7["重复使用签名"] --> C8["nonce 已递增"]
C8 --> C2
C2 --> C9["恢复出错误地址"]
C9 --> B5
end
攻击场景详解:
| 攻击方式 |
攻击者行为 |
防护机制 |
结果 |
| 篡改金额 |
修改 value 为更大数值 |
digest 包含 value |
ecrecover 得到错误地址 → 失败 |
| 篡改授权对象 |
修改 spender 为自己 |
digest 包含 spender |
ecrecover 得到错误地址 → 失败 |
| 延长有效期 |
修改 deadline |
digest 包含 deadline |
ecrecover 得到错误地址 → 失败 |
| 跨合约重放 |
在其他合约使用签名 |
DOMAIN_SEPARATOR 含合约地址 |
digest 不同 → 失败 |
| 跨链重放 |
在其他链使用签名 |
DOMAIN_SEPARATOR 含 chainId |
digest 不同 → 失败 |
| 重复使用 |
多次提交同一签名 |
nonce 递增机制 |
第二次 nonce 不匹配 → 失败 |
核心原理图示:
flowchart LR
A["签名 (v, r, s)"] <-->|"数学绑定"| B["digest"]
B --> C["任何篡改"]
C --> D["ecrecover 恢复出错误地址"]
D --> E["❌ 验证失败"]
为什么数学绑定是安全的?
ECDSA 签名的安全性基于椭圆曲线离散对数问题:
- 签名
(r, s) 是通过私钥 d 和消息哈希 z 计算得出
- 公式:s=k−1(z+r⋅d)modn
- 如果 z(digest)改变,用原签名恢复出的公钥就会不同
- 攻击者无法在不知道私钥的情况下为新的 digest 生成有效签名
💡 核心原理:签名 (v, r, s) 与 digest 是数学绑定的。任何篡改都会导致 ecrecover 恢复出错误的地址。这就是密码学的魔法!
4. ECDSA 密码学基础
4.1 椭圆曲线密码学概述
以太坊使用 secp256k1 椭圆曲线,与比特币相同。
椭圆曲线方程:
y2=x3+7(modp)
其中 p 是一个大素数:
p=2256−232−977
4.2 关键参数
secp256k1 曲线参数:
| 参数 |
含义 |
值 |
| p |
有限域大小 |
2256−232−977 |
| G |
生成点 (Generator) |
固定的曲线上的点 |
| n |
曲线阶 (点的个数) |
≈2256 |
生成点 G 的坐标:
Gx=0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
Gy=0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
4.3 密钥对生成
私钥 d:随机选择的 256 位整数,满足 1≤d<n
公钥 Q:椭圆曲线上的点
Q=d⋅G
这里 d⋅G 表示椭圆曲线上的点乘法(G 与自身相加 d 次)。
安全性基础:椭圆曲线离散对数问题 (ECDLP)
已知 G 和 Q=d⋅G,计算 d 在计算上不可行。
5. 椭圆曲线数学
5.1 点加法 (Point Addition)
给定曲线上两点 P=(x1,y1) 和 Q=(x2,y2),计算 R=P+Q=(x3,y3):
当 P=Q 时:
λ=x2−x1y2−y1(modp)
x3=λ2−x1−x2(modp)
y3=λ(x1−x3)−y1(modp)
5.2 点倍乘 (Point Doubling)
当 P=Q 时,计算 R=2P:
λ=2y13x12+a(modp)
对于 secp256k1,a=0,所以:
λ=2y13x12(modp)
5.3 标量乘法 (Scalar Multiplication)
计算 k⋅P(将 P 与自身相加 k 次):
使用 Double-and-Add 算法:
1 2 3 4 5 6 7 8 9
| 输入: 标量 k, 点 P 输出: Q = k·P
Q = O (无穷远点) for i from (bit_length(k) - 1) down to 0: Q = 2·Q // 倍乘 if bit i of k is 1: Q = Q + P // 加法 return Q
|
时间复杂度:O(logk) 次点运算
6. 签名与验证流程
6.1 ECDSA 签名算法
输入:
- 私钥 d
- 消息哈希 z=H(m)(256 位)
签名过程:
-
选择随机数 k,1≤k<n
-
计算曲线上的点:
(x1,y1)=k⋅G
-
计算 r:
r=x1modn
如果 r=0,返回步骤 1
-
计算 s:
s=k−1(z+r⋅d)modn
如果 s=0,返回步骤 1
-
签名为 (r,s)
v 的计算:
v=27+(y1mod2)
v 用于标识 y 坐标的奇偶性,帮助恢复公钥。
6.2 签名格式
以太坊签名格式:
block-beta
columns 3
block:sig["签名 (65 bytes)"]:3
r["r\n32 bytes"]
s["s\n32 bytes"]
v["v\n1 byte"]
end
| 组件 |
大小 |
说明 |
r |
32 bytes |
临时公钥点的 x 坐标 mod n |
s |
32 bytes |
签名标量值 |
v |
1 byte |
恢复标识符 (27 或 28) |
6.3 ECDSA 验证算法
输入:
- 公钥 Q
- 消息哈希 z
- 签名 (r,s)
验证过程:
-
验证 r,s∈[1,n−1]
-
计算:
u1=z⋅s−1modn
u2=r⋅s−1modn
-
计算曲线上的点:
(x1,y1)=u1⋅G+u2⋅Q
-
验证:
r=?x1modn
为什么这样能验证?
展开 u1⋅G+u2⋅Q:
u1⋅G+u2⋅Q=z⋅s−1⋅G+r⋅s−1⋅Q=z⋅s−1⋅G+r⋅s−1⋅d⋅G=s−1(z+r⋅d)⋅G=s−1⋅s⋅k⋅G(∵s=k−1(z+r⋅d))=k⋅G
这正是签名时计算 r 所用的点!
7. ecrecover 原理
7.1 公钥恢复算法
ecrecover 是以太坊的预编译合约,从签名恢复公钥:
输入:消息哈希 z,签名 (v,r,s)
过程:
-
从 r 恢复曲线上的点 R=(r,y)
- 使用 v 确定 y 的奇偶性
- 解方程 y2=r3+7(modp)
-
计算公钥:
Q=r−1(s⋅R−z⋅G)
-
返回地址:
address=keccak256(Qx∥Qy)[12:32]
7.2 为什么可以恢复公钥?
从签名方程:
s=k−1(z+r⋅d)modn
解出 d(私钥对应的公钥系数):
s⋅k=z+r⋅d
d=r−1(s⋅k−z)
由于 k⋅G=R,我们有:
d⋅G=r−1(s⋅k⋅G−z⋅G)=r−1(s⋅R−z⋅G)
所以:
Q=r−1(s⋅R−z⋅G)
7.3 Solidity 中的 ecrecover
1 2 3 4 5 6 7 8 9 10 11
| // 预编译合约,地址 0x01 function ecrecover( bytes32 hash, // 消息哈希 uint8 v, // 恢复标识符 bytes32 r, // 签名 r bytes32 s // 签名 s ) returns (address);
// 使用示例 address signer = ecrecover(messageHash, v, r, s); require(signer == expectedSigner, "Invalid signature");
|
8. 安全性分析
8.1 随机数 k 的重要性
致命错误:如果两次签名使用相同的 k,私钥将泄露!
假设两次签名 (r,s1) 和 (r,s2) 使用相同的 k:
s1=k−1(z1+r⋅d)
s2=k−1(z2+r⋅d)
则:
s1−s2=k−1(z1−z2)
k=s1−s2z1−z2
一旦知道 k,可以计算私钥:
d=r−1(s1⋅k−z1)
历史案例:2010 年 Sony PS3 私钥泄露,正是因为 ECDSA 签名使用了固定的 k。
8.2 签名可锻性 (Malleability)
对于有效签名 (r,s),(r,n−s) 也是有效签名。
解决方案:EIP-2 规定 s 必须在低半区:
s≤2n
1 2 3 4
| // OpenZeppelin ECDSA 库的检查 if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { return (address(0), RecoverError.InvalidSignatureS); }
|
8.3 重放攻击防护
| 攻击类型 |
防护机制 |
| 跨链重放 |
Domain Separator 包含 chainId |
| 跨合约重放 |
Domain Separator 包含 verifyingContract |
| 同合约重放 |
nonce 递增机制 |
| 过期签名 |
deadline 时间戳检查 |
8.4 Permit 特有风险
钓鱼攻击:
- 恶意网站诱导用户签名
- 用户以为在签授权给 DEX
- 实际签名授权给攻击者地址
缓解措施:
- 钱包清晰展示签名内容
- 用户仔细核对 spender 地址
- 设置合理的 deadline
9. 实际应用场景
9.1 Permit 使用示例
前端签名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| import { ethers } from "ethers";
async function signPermit( token: Contract, owner: Signer, spender: string, value: BigNumber, deadline: number ) { const nonce = await token.nonces(owner.address); const name = await token.name(); const chainId = await owner.getChainId();
const domain = { name: name, version: "1", chainId: chainId, verifyingContract: token.address, };
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: owner.address, spender: spender, value: value, nonce: nonce, deadline: deadline, };
const signature = await owner._signTypedData(domain, types, message); const { v, r, s } = ethers.utils.splitSignature(signature);
return { v, r, s }; }
|
合约调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| // 单笔交易完成授权 + 转账 function permitAndTransfer( address token, address from, address to, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external { // 使用签名授权 IERC20Permit(token).permit(from, address(this), amount, deadline, v, r, s);
// 执行转账 IERC20(token).transferFrom(from, to, amount); }
|
9.2 Permit2 (Uniswap)
Permit2 是 Uniswap 开发的通用授权协议:
优势:
- 统一的授权管理
- 支持批量授权
- 支持签名转账
- 适用于所有 ERC-20(包括不支持 Permit 的)
1 2 3 4 5 6 7 8 9 10 11 12 13
| // Permit2 签名授权 struct PermitSingle { PermitDetails details; address spender; uint256 sigDeadline; }
struct PermitDetails { address token; uint160 amount; uint48 expiration; uint48 nonce; }
|
9.3 Gasless 交易(元交易)
结合 Permit 实现无 Gas 交易:
1 2 3 4 5
| 1. 用户签名 Permit(离线) 2. 用户签名交易意图(离线) 3. Relayer 提交两个签名到链上 4. Relayer 支付 Gas 5. 用户支付代币作为手续费
|
参考资料
EIP 标准文档
椭圆曲线与 secp256k1
ECDSA 算法详解
ecrecover 与公钥恢复
Permit 与 EIP-712 实现
安全性研究
代码实现参考
附录:常用常量
secp256k1 参数
1 2 3 4
| p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
EIP-712 类型哈希
1 2 3 4 5 6 7 8 9
| // Domain type hash bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" );
// Permit type hash bytes32 constant PERMIT_TYPEHASH = keccak256( "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" );
|