erc2612-permit链下授权

erc2612-permit链下授权

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 对比

对比项传统 approveERC-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。