用链下签名打造超低 Gas ERC20 代币:原理、代码与落地指南

·

关键词:链下签名、EIP-712、permit、ERC20、无 Gas、以太坊交易、智能合约优化、gas 费用

面向新手与进阶开发者,本文拆解「链下预先签名 + 链上 permit 执行」这一套流行模式,教你把普通 ERC20 升级为「用户零 gas 也能授权转账」的友好代币。


1. 「无 Gas」神话的真相

以太坊永远不可能完全免 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 的构造

在构造函数中把 nameversionchainId、合约地址打包 keccak256,生成全局分隔符。这是「跨链、跨合约」的防火墙。

4.2 PERMIT_TYPEHASH 定义

bytes32 public constant PERMIT_TYPEHASH = keccak256(
  "Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed)"
);

注意:holderspendernonceexpiryallowed 字段顺序必须与链下签名完全对齐,否则 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 签名。

  1. 构造 DOMAIN_SEPARATOR

    const domain = {
      name: TOKEN_NAME,
      version: "1",
      chainId: chainId,
      verifyingContract: tokenAddress
    };
  2. 组装消息并哈希

    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);
  3. 拆分 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 的代币授权流程。


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. 给产品经理的三条设计建议

  1. 把 public RPC + relayer 做成 SDK,让前端只用管签名,其余全部自动。
  2. 设置推荐有效期(例如 30 分钟),体验与安全兼得。
  3. 用前端事件通知用户“正在等待 relayer 打包”,降低焦虑感。

👉 获取完整的零 gas 代币示例仓库与部署脚本,十分钟上手。


9. 结语

链下签名授权(permit 模式)巧妙地重构了“谁来付手续费”的博弈,带来低门槛、高可用、可组合的新一代 DeFi 体验。你已经掌握原理、工具与落地路径,下一步就是把它搬进你的产品,让用户真正感受「点击即完成」的神奇。祝你编码愉快!