关键词:链下签名、EIP-712、permit、ERC20、无 Gas、以太坊交易、智能合约优化、gas 费用
面向新手与进阶开发者,本文拆解「链下预先签名 + 链上 permit 执行」这一套流行模式,教你把普通 ERC20 升级为「用户零 gas 也能授权转账」的友好代币。
1. 「无 Gas」神话的真相
以太坊永远不可能完全免 gas,但可以转移付款方:
- 传统流程:用户调用
approve→ 自己付 gas。 - 新流程:用户在链下签一段消息 → 中继方把签名喂给
permit→ 中继方付 gas。
本质上,把「手续费」这项成本转嫁给愿意承担的服务商、矿池或 dApp 补贴计划,对终端用户而言就是“免费”体验。
2. 核心组件一分钟速览
| 名称 | 作用 |
|---|---|
| DOMAIN_SEPARATOR | 限定签名只在指定合约与链 ID 生效 |
| PERMIT_TYPEHASH | 把函数名+参数类型做哈希,防止签名误用 |
| nonces | 每个 holder 的签名序号,用完即废,防重放 |
| permit | 链上校验签名,成功后直接修改 allowance |
3. 协议细节:从 EIP-712 到 EIP-2612
3.1 EIP-712:结构化哈希
EIP-712 把「一堆参数」打包成人类可读的结构化数据,利于钱包展示也利于链上验证。
3.2 EIP-2612:专为 ERC20 设计的 permit 规范
在 approve 之外新增 permit 方法,并统一字段顺序与类型,使任何兼容的钱包或 SDK 都能一键生成签名。
4. Dai 合约实战:四步理解 permit 源码
下面以 MakerDAO 早期 Dai 为例,拆解 4 个关键点:
string public constant name = "Dai Stablecoin";
string public constant version = "1";
bytes32 public DOMAIN_SEPARATOR;
mapping (address => uint) public nonces;4.1 DOMAIN_SEPARATOR 的构造
在构造函数中把 name、version、chainId、合约地址打包 keccak256,生成全局分隔符。这是「跨链、跨合约」的防火墙。
4.2 PERMIT_TYPEHASH 定义
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed)"
);注意:holder、spender、nonce、expiry、allowed 字段顺序必须与链下签名完全对齐,否则 ecrecover 会恢复出错误地址。
4.3 permit 的核心校验
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(abi.encode(
PERMIT_TYPEHASH,
holder, spender, nonce, expiry, allowed
))
));
require(holder == ecrecover(digest, v, r, s), "Dai/invalid-permit");
require(expiry == 0 || now <= expiry, "Dai/permit-expired");
require(nonce == nonces[holder]++, "Dai/invalid-nonce");只要任一条件错,马上 revert。这就是持币人敢把签名给第三方的底气:签名只能被正确使用一次,且仅在指定时间和合约内生效。
4.4 授权额度设置
uint wad = allowed ? uint(-1) : 0;
allowance[holder][spender] = wad;
emit Approval(holder, spender, wad);Dai 版本只允许“全开”或“全关”。如果你追求更灵活额度,可直接继承 OpenZeppelin ERC20Permit 合约,轻松支持 uint256 任意值。
5. 从 JavaScript 生成链下签名(三步搞定)
先用 ethers.js 完全重现合约的哈希计算,再用 ethereumjs-util 给 digest 签名。
构造 DOMAIN_SEPARATOR
const domain = { name: TOKEN_NAME, version: "1", chainId: chainId, verifyingContract: tokenAddress };组装消息并哈希
const types = { Permit: [ { name: "holder", type: "address" }, { name: "spender", type: "address" }, { name: "nonce", type: "uint256" }, { name: "expiry", type: "uint256" }, { name: "allowed", type: "bool" } ] }; const value = { holder: user1.address, spender: user2.address, nonce: nonce, expiry: deadline, allowed: true }; const signature = await signer._signTypedData(domain, types, value);拆分 v,r,s 喂给合约
const { v, r, s } = ethers.utils.splitSignature(signature); await token.connect(user2).permit(user1.address, user2.address, nonce, deadline, true, v, r, s);
此时 user1 零 gas 完成授权,user2 接下来可直接 transferFrom,用户体验顺畅到飞起。
6. 优势与适用场景
- gas 补贴运营:交易所空投、小游戏每日任务、社交网络打赏。
- 批量授权:做市机器人一次性替上千地址提交。
- 元交易:结合 relayer 让用户甚至连钱包里的 ETH 都不需要有。
7. 常见问题 FAQ
Q1:签名真的可以无限复用吗?
A:不可以。nonces 会自增,任何已用过的签名立即失效。
Q2:离线生成的签名有效期多久?
A:由 expiry 决定,设为零表示永不过期,建议设置 1~7 天即可平衡便利与安全。
Q3:如何快速为现有 ERC20 加上 permit?
A:直接继承 OpenZeppelin 的 ERC20Permit.sol 即可,一行代码搞定。
Q4:合约升级会不会破坏旧签名?
A:会。因为 DOMAIN_SEPARATOR 含合约地址与 chainId,升级部署到新地址就必须重新签名。
Q5:EIP-712 的 field 顺序写错会怎样?
A:链上恢复出的地址与 holder 不一致,交易 revert,且报错信息同为 invalid-permit,调试时请逐字节核对。
Q6:实际能省多少 gas?
A:对比标准 approve 的 46 k gas,permit 只需 ~12 k 的验证 gas + relayer 的逻辑,典型节省 70% 以上。
8. 给产品经理的三条设计建议
- 把 public RPC + relayer 做成 SDK,让前端只用管签名,其余全部自动。
- 设置推荐有效期(例如 30 分钟),体验与安全兼得。
- 用前端事件通知用户“正在等待 relayer 打包”,降低焦虑感。
👉 获取完整的零 gas 代币示例仓库与部署脚本,十分钟上手。
9. 结语
链下签名授权(permit 模式)巧妙地重构了“谁来付手续费”的博弈,带来低门槛、高可用、可组合的新一代 DeFi 体验。你已经掌握原理、工具与落地路径,下一步就是把它搬进你的产品,让用户真正感受「点击即完成」的神奇。祝你编码愉快!