WTF-Solidity 教程:ERC-2612 ERC20Permit 签名授权实战与源码剖析
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
导读
本文基于 WTF-Solidity 仓库的 ERC20Permit 专题(Languages/pt-br/53_ERC20Permit/readme.md,即 WTF Solidity 极简入门第 53 讲)整理而成。ERC20Permit 是 ERC20 代币标准在 EIP-2612 中提出的扩展,它让授权操作可以脱离msg.sender、改由 EIP-712 链下签名完成,将"授权 + 交易"两步压缩为一步,并被USDC、ARB等主流代币采用。读完本文,你将掌握 ERC20Permit 的接口定义、合约实现原理(含 nonce 防重放机制与 EIP-712 域分隔符)、基于 ethers v6 的链下签名流程,以及如何用 Remix 完整复现一次"签名即授权"。
ERC20 授权的痛点:为什么需要 Permit?
我们在仓库的 31_ERC20/readme.md 中介绍过 ERC20——以太坊最流行的代币标准。它流行的一个核心原因,是approve与transferFrom两个函数配合使用,使得代币不仅能在外部账户(EOA)之间转移,还能被其他合约(如 DEX)代为划转。
但 ERC20 的approve函数有一个硬性限制:只有代币所有者本人才能调用。这意味着所有 ERC20 代币的初始操作都必须由 EOA 发起。以用户 A 在去中心化交易所用USDT兑换ETH为例,必须依次完成两个交易:
- 用户 A 调用
approve,将USDT授权给交易所合约; - 用户 A 再次调用合约完成兑换。
这一步两步流程不仅繁琐,还要求用户 A 全程持有ETH来支付两笔交易的 gas。如果用户只有USDT而没有ETH,甚至连第一步授权都无法发起。
ERC20Permit:用 EIP-712 签名替代 approve 交易
EIP-2612 提出的 ERC20Permit 正是为了解决上述痛点:它在 ERC20 标准上增加了一个permit函数,允许用户通过 EIP-712 签名修改授权,而不是依赖msg.sender。这带来两点直接收益:
- 授权这步仅需用户在链下签名,减少一笔交易:签名的 gas 成本为零(或由执行方代付);
- 签名后用户可委托第三方执行后续交易,自己无需持有 ETH:用户 A 可以把签名发给拥有 gas 的第三方 B,由 B 代为提交交易,完成授权乃至后续兑换。
下图直观对比了两种模式的交易数量差异:传统 ERC20 需要两笔链上交易(Approve + TransferFrom),而 ERC20Permit 只需一笔交易(签名本身在链下完成):
需要注意的是,ERC20Permit 依赖的 EIP-712 签名标准,正是仓库 52_EIP712(WTF Solidity 第 52 讲)讲解的"结构化数据签名":用户在钱包中看到的是可读的Permit类型化数据(owner、spender、value、nonce、deadline),而不是一串难以核验的十六进制哈希,这既提升了安全性也改善了用户体验。
IERC20Permit 接口合约:3 个核心函数
仓库中 ERC20Permit 的接口定义位于 Languages/pt-br/53_ERC20Permit/IERC20Permit.sol,它定义了 3 个函数:
permit():根据owner的签名,将owner的 ERC20 代币余额授权给spender,数量为value。接口注释明确了 4 条硬性要求:spender不能是零地址;deadline必须是未来的时间戳(保证签名时效性);v、r、s必须是owner对 EIP-712 格式函数参数的有效签名(secp256k1 签名三分量);- 签名必须使用
owner当前的 nonce。
nonces():返回owner当前的 nonce。每次为permit()生成签名时都必须包含此值;每次成功调用permit()都会将owner的 nonce 加 1,从而防止同一签名被多次使用(防重放)。DOMAIN_SEPARATOR():返回用于编码permit()签名的域分隔符(domain separator),其定义遵循 EIP-712,用于将签名限定在特定链、特定合约上,防止跨链/跨合约重放。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; interface IERC20Permit { function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external; function nonces(address owner) external view returns (uint256); // solhint-disable-next-line func-name-mixedcase function DOMAIN_SEPARATOR() external view returns (bytes32); }ERC20Permit 合约实现:源码级剖析
仓库中的完整实现位于 Languages/pt-br/53_ERC20Permit/ERC20Permit.sol,它继承自ERC20、IERC20Permit与 OpenZeppelin 的EIP712。合约包含2 个状态变量:
_nonces:address => uint的映射,记录所有用户当前的 nonce 值;_PERMIT_TYPEHASH:常量,记录permit()函数的类型哈希,其值为keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")——注意字段顺序与接口参数一一对应,链下签名时也必须使用完全相同的类型定义。
合约包含5 个函数:
- 构造函数:
constructor(string memory name, string memory symbol) EIP712(name, "1") ERC20(name, symbol){}——同时初始化 ERC20 的name/symbol和 EIP-712 的name/version(固定为"1"); permit():最核心的函数,执行"检查时效 → 还原消息哈希 → 恢复签名者 → 校验身份 → 授权"五步;nonces():返回_nonces[owner];DOMAIN_SEPARATOR():返回_domainSeparatorV4();_useNonce():内部函数,返回用户当前 nonce 并自增 1("消费 nonce")。
permit() 的完整执行链路
function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public virtual override { // 1. 检查 deadline require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); // 2. 拼接 structHash,并消费 nonce bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)); bytes32 hash = _hashTypedDataV4(structHash); // 3. 从签名和消息恢复 signer,并验证签名 address signer = ECDSA.recover(hash, v, r, s); require(signer == owner, "ERC20Permit: invalid signature"); // 4. 执行授权 _approve(owner, spender, value); }各步骤的底层原理可结合 OpenZeppelin 源码印证:
- 第 1 步时效检查:
block.timestamp <= deadline确保签名在有效期内,过期签名直接回滚; - 第 2 步消息哈希:先用
abi.encode把_PERMIT_TYPEHASH与五个参数编码、再取keccak256得到structHash,随后调用_hashTypedDataV4()。该函数定义在 lib/openzeppelin-contracts/contracts/utils/cryptography/EIP712.sol,实现为MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), structHash),即把域分隔符与结构化数据哈希拼接后再做一次keccak256,得到最终待签名摘要。域分隔符由_buildDomainSeparator()生成:keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this))),其中TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")。正是这一结构把签名锁定在"特定合约 + 特定链"上; - 第 3 步签名恢复:
ECDSA.recover(hash, v, r, s)定义在 lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol,从(v, r, s)三分量恢复出签名地址,若恢复失败或签名非法会回滚。恢复出的signer必须严格等于owner,否则说明签名的所有者与授权发起者不一致; - 第 4 步授权:调用 ERC20 内部的
_approve(owner, spender, value),触发标准的Approval事件——至此授权完成,且整个过程不需要owner亲自发起交易。
仓库根目录的官方实现还附带了mint函数(Languages/pt-br/53_ERC20Permit/ERC20Permit.sol末尾的mint(uint amount)),便于在测试环境中铸造代币,方便演练签名授权流程。
与 OpenZeppelin 官方 ERC20Permit 的对照
仓库的lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sol提供了 OpenZeppelin 官方实现。对比可见两者核心逻辑完全一致:相同的PERMIT_TYPEHASH类型哈希、相同的"过期检查 → structHash →_hashTypedDataV4→ECDSA.recover→ 身份校验 →_approve"链路。差异主要在工程细节上:官方版本使用ERC2612ExpiredSignature/ERC2612InvalidSigner自定义错误而非require字符串(更省 gas),并通过Nonces抽象统一管理 nonce。从源码结构可以看出,本教程的简易实现是为了教学可读性而刻意精简的——理解它的五步流程后,再读官方实现会非常轻松。
Remix 实战复现:从部署到"签名即授权"
教程文档给出了完整的 Remix 复现步骤,我们结合仓库的signERC20Permit.html签名工具逐一展开。
第 1 步:部署 ERC20Permit 合约
在 Remix 中编译并部署 Languages/pt-br/53_ERC20Permit/ERC20Permit.sol,构造函数参数name与symbol均设为WTFPermit。部署完成后,先用合约自带的mint给自己铸造一些代币,保证owner有余额可授权。
第 2 步:链下签名,获取 v / r / s
仓库在 Languages/pt-br/53_ERC20Permit/signERC20Permit.html 提供了一套基于 ethers v6 的浏览器签名工具。操作流程如下:
- 浏览器打开该 HTML 文件(需处于支持 MetaMask 注入的环境中,也可直接作为本地静态页面运行);
- 将
Contract Address改为刚部署的ERC20Permit合约地址,其余信息按下面给出的参数填写; - 依次点击
Connect MetaMask(连接钱包)与Sign Permit(发起签名); - 在 MetaMask 弹出的 EIP-712 签名确认框中核对
Permit结构化数据后签名,页面会自动解析出v、r、s三分量。
文档给出的示例参数如下(注意:owner必须与签名钱包一致,例如 Remix 默认测试钱包):
owner: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 spender: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2 value: 100 deadline: 115792089237316195423570985008687907853269984665640564039457584007913129639935 private_key: 503f38a9c967ed597e47fe25643985f032b072db8075426a92110f82df48dfcb其中deadline使用了uint256的最大值2^256 - 1,表示"永不过期",是实践中常见的便利写法。
签名页面(MetaMask 的 EIP-712 类型化数据签名请求)效果如下图所示——用户在签名前可以看到Permit的全部字段(owner、spender、value、nonce、deadline),这正是 EIP-712 相对普通personal_sign的核心安全优势:
从 signERC20Permit.html 的源码可以看到签名工具的完整逻辑:它用ethers.BrowserProvider(window.ethereum)获取钱包 Provider,构造domain(含name: "WTFPermit"、version: "1"、chainId、verifyingContract)、types(与链上_PERMIT_TYPEHASH完全对应的Permit五字段类型)和message(owner/spender/value/nonce/deadline),最后调用signer.signTypedData(domain, types, message)得到签名串,再用ethers.Signature.from(signature)拆出v、r、s。链下的类型定义与链上的_PERMIT_TYPEHASH必须严格一致,否则ECDSA.recover恢复出的地址不会等于owner,permit()会以 "invalid signature" 回滚。
第 3 步:调用 permit() 完成链上授权
将第 2 步获取的(v, r, s)连同owner、spender、value、deadline一起作为参数,在 Remix 中调用合约的permit()方法。这笔交易可以由任何地址(包括第三方 B)发起——这正是 ERC20Permit "签名后无需 owner 持有 ETH" 的体现。
第 4 步:验证授权结果
调用合约的allowance()方法,传入对应的owner和spender,即可看到授权额度已变为value(示例中为 100),说明permit()已成功执行_approve。此后,spender就可以通过transferFrom划转这笔额度,完成诸如 DEX 兑换之类的后续操作。
安全注意事项:签名即授权,谨慎再谨慎
ERC20Permit 把"链下签名"与"链上授权"绑定在一起,便利的同时也放大了风险:
钓鱼攻击风险:黑客会利用这一特性诱导用户签署恶意签名,一旦签名泄露,攻击者可直接调用
permit()把受害者资产授权给自己并划走。文档记载了 2023 年 4 月一起针对 USDC 的签名钓鱼攻击,导致一位用户损失 228 万美元。签名时,一定要仔细阅读签名内容,确认spender、value、deadline等每一项数据都符合预期,切勿盲目点击钱包的 Sign 按钮。nonce 抢跑导致的 DoS 风险:
permit()执行时会消耗当前 nonce(_useNonce自增 1)。部分合约在集成permit时,如果其某个函数内部包含 permit 操作(如"先签名授权、再在单笔交易内完成转账"),攻击者可以通过抢跑(front-run)先提交permit()交易,从而占用该 nonce,使目标交易因 nonce 不匹配而回滚,造成拒绝服务。集成方在设计"签名授权 + 交易"原子化流程时需要评估这一攻击面。
总结
本讲介绍了 ERC20Permit(EIP-2612)——一个扩展自 ERC20 标准、支持用户通过 EIP-712 链下签名完成授权的机制。它把传统 ERC20 两步式授权(approve+ 交易)压缩为"一次签名 + 一笔交易",token 持有者无需持有 ETH 即可完成授权,因此被USDC、ARB等大量项目采用。配套的接口合约、实现合约与浏览器签名工具均可在仓库 Languages/pt-br/53_ERC20Permit 目录中找到;OpenZeppelin 官方实现位于 lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sol,可作为生产级参考。同时务必牢记:签名即授权,一个签名就可能卷走你的全部资产,签名前请谨慎核对内容。
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考