ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

WTF Solidity 第53讲:ERC-2612 ERC20Permit 签名授权实战指南

2026/9/14 10:20:01 拓冰建站 浏览量
WTF Solidity 第53讲:ERC-2612 ERC20Permit 签名授权实战指南 WTF Solidity 第53讲ERC-2612 ERC20Permit 签名授权实战指南【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity导读本文讲解 ERC20 代币标准的重要扩展ERC-2612ERC20Permit它通过 EIP-712 类型化数据签名让代币持有者在链下完成授权签名无需发送approve交易从而把授权与转账两笔交易合并为一笔且签名后可由持有 gas 的第三方代为执行后续交易。读完本文你将掌握IERC20Permit接口与ERC20Permit合约的完整实现原理、permit()签名验证与 nonce 防重放机制并能在 Remix MetaMask 环境中完成从部署、链下签名到链上permit()授权的全流程复现。本讲完整代码位于 53_ERC20Permit/ERC20Permit.sol接口定义见 53_ERC20Permit/IERC20Permit.sol链下签名辅助页面见 53_ERC20Permit/signERC20Permit.html。一、ERC20 授权模型的痛点我们在 31 讲 ERC20 极简入门 中介绍过ERC20 是以太坊上最流行的代币标准。它流行的主要原因之一是approve与transferFrom两个函数搭配使用使得代币不仅能在外部拥有账户EOA之间直接转移还能被其他合约如去中心化交易所代为划转。但该模型的局限在于只有代币所有者本人才能调用approve这意味着所有 ERC20 代币的初始授权操作都必须由 EOA 发起一笔链上交易。以用户 A 在去中心化交易所用 USDT 兑换 ETH 为例整个过程必须拆成两笔交易用户 A 调用approve把 USDT 授权给交易所合约用户 A 再调用交易所合约执行 USDT → ETH 的兑换。这种先授权、后转账的两步流程非常繁琐并且用户必须持有 ETH来支付两笔交易的 gas 费——即使只是授权也需要一笔链上交互。上图展示了传统 ERC20 授权模式下的交互流程所有授权操作都由代币持有者EOA直接发起这是 ERC20Permit 要解决的体验瓶颈。二、ERC20Permit 与 EIP-2612EIP-2612提出了ERC20Permit标准对 ERC20 做了向后兼容的扩展新增一个permit函数允许用户通过EIP-712 类型化数据签名来修改授权额度而不是依赖msg.sender必须是代币所有者这一限制。该标准已被以太坊纳入生态标准并被USDC、ARB等主流代币采用。相比传统流程ERC20Permit 带来两点核心改进省一笔交易授权这一步只需要用户在链下签名不需要发送链上交易授权动作由后续交易中调用permit()来顺带完成持有者无需持有 ETH签名完成后用户可以把签名交给持有 gas 的第三方如做市商、中继器、DApp 后端由第三方代为调用permit()与后续操作。例如 A 将签名发给拥有 gas 的 B委托 B 执行后续交易A 自己不需要 ETH。2.1 前置知识EIP-712 类型化数据签名permit()的签名基于 52 讲 EIP-712 类型化数据签名 标准。与 EIP-191 的personal_sign用户只看到一串十六进制哈希不同EIP-712 在钱包请求签名时会展示签名消息的原始结构化数据用户可以核对owner、spender、value、deadline等字段后再签名从而避免盲签风险。EIP-712 签名的核心是域分隔符domain separator与结构化消息哈希的拼接digest keccak256(\x19\x01 || domainSeparator || hashStruct(message))其中domainSeparator由name、version、chainId、verifyingContract计算得出确保同一份签名只能被特定链上的特定合约验证使用。三、IERC20Permit 接口合约首先学习 ERC20Permit 的接口合约它定义了 3 个函数完整代码见 53_ERC20Permit/IERC20Permit.solpermit()根据owner的签名将owner的 ERC20 代币余额授权给spender数量为value。要求spender不能是零地址deadline必须是未来的时间戳block.timestamp deadlinev、r、s必须是owner对 EIP-712 格式的函数参数的有效secp256k1签名签名必须使用owner当前的 nonce见nonces()。nonces(address owner)返回owner当前的 nonce。每次为permit()生成签名时都必须包含此值每次成功调用permit()都会将owner的 nonce 增加 1从而防止同一签名被重复使用防重放。DOMAIN_SEPARATOR()返回用于编码permit()签名的域分隔符domain separator定义遵循 EIP-712。// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; /** * dev ERC20 Permit 扩展的接口允许通过签名进行批准如 https://eips.ethereum.org/EIPS/eip-2612[EIP-2612]中定义。 */ interface IERC20Permit { /** * dev 根据owner的签名, 将 owner 的ERC20余额授权给 spender数量为 value */ function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external; /** * dev 返回 owner 的当前 nonce。每次为 {permit} 生成签名时都必须包括此值。 */ function nonces(address owner) external view returns (uint256); /** * dev 返回用于编码 {permit} 的签名的域分隔符domain separator */ // solhint-disable-next-line func-name-mixedcase function DOMAIN_SEPARATOR() external view returns (bytes32); }四、ERC20Permit 合约实现接下来编写一个实现了IERC20Permit全部接口的ERC20Permit合约它同时继承 OpenZeppelin 的ERC20与EIP712基础合约。完整代码见 53_ERC20Permit/ERC20Permit.sol。4.1 状态变量_noncesaddress uint映射记录所有用户当前的 nonce 值_PERMIT_TYPEHASH常量记录permit()函数的类型哈希对应 EIP-712 结构化类型Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)。4.2 合约代码// 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; /** * dev ERC20 Permit 扩展的接口允许通过签名进行批准如 https://eips.ethereum.org/EIPS/eip-2612[EIP-2612]中定义。 * * 添加了 {permit} 方法可以通过帐户签名的消息更改帐户的 ERC20 余额参见 {IERC20-allowance}。 * 通过不依赖 {IERC20-approve}代币持有者的帐户无需发送交易因此完全不需要持有 Ether。 */ contract ERC20Permit is ERC20, IERC20Permit, EIP712 { mapping(address uint) private _nonces; bytes32 private constant _PERMIT_TYPEHASH keccak256(Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)); /** * dev 初始化 EIP712 的 name 以及 ERC20 的 name 和 symbol */ constructor(string memory name, string memory symbol) EIP712(name, 1) ERC20(name, symbol){} /** * dev See {IERC20Permit-permit}. */ function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public virtual override { // 检查 deadline require(block.timestamp deadline, ERC20Permit: expired deadline); // 拼接 Hash bytes32 structHash keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)); bytes32 hash _hashTypedDataV4(structHash); // 从签名和消息计算 signer并验证签名 address signer ECDSA.recover(hash, v, r, s); require(signer owner, ERC20Permit: invalid signature); // 授权 _approve(owner, spender, value); } /** * dev See {IERC20Permit-nonces}. */ function nonces(address owner) public view virtual override returns (uint256) { return _nonces[owner]; } /** * dev See {IERC20Permit-DOMAIN_SEPARATOR}. */ function DOMAIN_SEPARATOR() external view override returns (bytes32) { return _domainSeparatorV4(); } /** * dev 消费nonce: 返回 owner 当前的 nonce并增加 1。 */ function _useNonce(address owner) internal virtual returns (uint256 current) { current _nonces[owner]; _nonces[owner] 1; } }合约包含 5 个函数构造函数同时初始化 ERC20 的name、symbol和 EIP-712 的nameversion 固定为字符串1。本仓库版本还额外提供了mint()铸造函数便于测试时向调用者铸造代币permit()最核心的函数。先检查签名是否过期再拼接类型哈希还原签名消息用 ECDSA 恢复签名者地址并校验其是否等于owner最后调用 ERC20 的_approve()完成授权nonces()实现接口中的 nonce 查询DOMAIN_SEPARATOR()返回当前链的域分隔符_useNonce()内部函数消费 nonce——返回用户当前 nonce 并将_nonces[owner]加 1保证每个签名只能使用一次。五、permit() 签名验证链路源码级解析permit()内部虽然只有短短几行但背后是一条完整的 EIP-712 签名验证调用链下面结合仓库内 OpenZeppelin 源码逐层拆解。5.1 第一步计算结构化消息哈希bytes32 structHash keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));其中_PERMIT_TYPEHASH是类型哈希keccak256(Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline))与链下签名时钱包展示的结构化类型见第六节的types定义完全一致保证链下签什么、链上验什么。注意这里传入的是_useNonce(owner)的返回值它先读取owner当前 nonce 用于拼哈希然后立刻自增 1。这意味着即使签名无效signer ! owner回滚nonce 也已经改变——因此每个 nonce 对应的签名最多只能被尝试使用一次有效防止重放攻击。5.2 第二步_hashTypedDataV4生成 EIP-712 摘要bytes32 hash _hashTypedDataV4(structHash);_hashTypedDataV4来自 OpenZeppelin 的 EIP712 基础合约其实现为function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) { return MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), structHash); }即最终摘要为keccak256(\x19\x01 || domainSeparator || structHash)其中\x19\x01前缀与 EIP-191 兼容。域分隔符的生成_domainSeparatorV4()在 EIP712.sol 中实现。构造函数会把 name、version 的哈希、block.chainid、address(this)缓存为 immutable 变量_domainSeparatorV4()会检测当前address(this)与chainid是否与缓存一致一致则直接返回缓存值节省 gas不一致如分叉或代理场景则通过_buildDomainSeparator()重新计算function _buildDomainSeparator() private view returns (bytes32) { return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this))); }其中TYPE_HASH keccak256(EIP712Domain(string name,string version,uint256 chainId,address verifyingContract))。这条域分隔符机制保证了签名只能被同一链chainId 相同上的同一合约地址verifyingContract验证通过签名无法跨链、跨合约复用。5.3 第三步ECDSA 恢复签名者并校验address signer ECDSA.recover(hash, v, r, s); require(signer owner, ERC20Permit: invalid signature);ECDSA.recover从(v, r, s)三元组中恢复出签名者的地址只有恢复出的signer恰好等于代币持有者owner时授权才被认可。从源码实现看ECDSA.recover内部还会校验签名长度v的取值、s的上界等防止延展性攻击。5.4 第四步执行授权_approve(owner, spender, value);_approve是 OpenZeppelin ERC20.sol 的内部函数它会检查owner与spender均非零地址零地址分别触发ERC20InvalidApprover、ERC20InvalidSpender错误更新allowance[owner][spender]并释放Approval事件——与直接调用approve的效果完全一致只是触发者从msg.sender换成了链下签名验证通过的owner。六、Remix 复现从部署到签名授权下面在 Remix MetaMask 环境下完整复现 ERC20Permit 的授权流程。步骤 1部署合约部署 ERC20Permit.sol构造函数传入name和symbol本例均设为WTFPermit。步骤 2链下签名用浏览器打开 signERC20Permit.html这是一个基于 ethers.js v6 的签名辅助页面内部通过signer.signTypedData(domain, types, message)构造 EIP-712 签名将Contract Address改为部署的ERC20Permit合约地址依次点击Connect MetaMask和Sign ERC20Permit按钮页面会展示签名结果的v、r、s供合约验证使用。签名时必须使用部署合约的钱包例如 Remix 测试钱包。以下是示例参数在 HTML 页面中按此填写name: WTFPermit version: 1 chainId: 1 owner: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 spender: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2 value: 100 nonce: 0 deadline: 115792089237316195423570985008687907853269984665640564039457584007913129639935 private_key: 503f38a9c967ed597e47fe25643985f032b072db8075426a92110f82df48dfcb几点说明deadline取的是uint256最大值2^256 - 1相当于永不过期。生产环境中应设为具体的时间戳合约会用require(block.timestamp deadline, ERC20Permit: expired deadline)校验nonce必须与链上nonces(owner)的当前值一致首次部署为 0否则签名验证会失败HTML 页面中构造的domain包含name、version(1)、chainId、verifyingContract与合约内 EIP-712 域分隔符严格对应types中的Permit结构字段顺序也与_PERMIT_TYPEHASH完全一致——这也是类型化签名在钱包中会完整展示这些字段、用户可以核验后再签名的原因。上图是signERC20Permit.html的运行界面页面读取钱包地址、ChainID、ETH 余额并在点击签名后展示签名原文以及拆解出的v、r、s。步骤 3调用 permit() 完成授权在 Remix 中调用合约的permit()方法传入owner、spender、value、deadline以及步骤 2 获得的v、r、s。交易成功后授权即已完成——注意这一步可以由任何持有 gas 的第三方发起代币持有者本人不需要支付 gas。步骤 4验证授权结果调用合约的allowance()方法输入相应的owner和spender可以看到allowance已更新为value本例为 100说明链下签名成功转化为链上授权之后即可配合transferFrom完成代币划转。七、安全注意事项ERC20Permit 用链下签名代替链上授权在改善体验的同时也引入了新的攻击面7.1 签名钓鱼攻击链下签名授权一旦被滥用黑客可以骗取用户签名并盗走资产。2023 年 4 月就发生过一起针对 USDC 的签名钓鱼攻击导致一位用户损失了高达 228 万美元的资产。签名时一定要谨慎阅读签名内容得益于 EIP-712钱包会展示签名消息的原始结构化数据授权对象、金额、deadline 等用户务必逐项核对确认不是恶意的授权请求后再签名。这是 ERC20Permit 安全性的第一道也是最重要的一道防线。7.2 permit 集成带来的 DoS 风险一些合约在集成permit时还可能引入拒绝服务DoS风险。因为permit()在执行时会消耗掉当前的 nonce 值如果某个合约函数内部包含了permit操作攻击者可以抢跑front-run先执行permit从而占用目标用户的 nonce使目标交易因 nonce 不匹配而回滚。设计集成permit的协议函数时需要评估该攻击路径并采取相应缓解措施如限制调用者、引入重试机制等。八、总结本讲介绍了ERC20PermitEIP-2612一个对 ERC20 标准的向后兼容扩展通过permit()函数让代币持有者使用EIP-712 链下签名完成授权配合 nonce 机制防重放。它的核心价值在于授权操作不再需要 EOA 发起链上交易把授权 转账两笔交易合并为一笔显著改善用户体验持有者签完名即可离线后续交易可由持有 gas 的第三方代为执行持有者无需持有 ETH。该标准已被USDC、ARB等大量主流项目采用是 DeFi 基础设施中高频使用的能力。但同时链下签名也放大了签名钓鱼和 permit 抢跑 DoS 的风险——一个签名就可能卷走你的全部资产签名时务必谨慎核对内容。如需进一步深入可结合 52 讲 EIP-712 类型化数据签名 理解域分隔符的构造原理对照本仓库 lib/openzeppelin-contracts/contracts/utils/cryptography/EIP712.sol 与 lib/openzeppelin-contracts/contracts/utils/cryptography/MessageHashUtils.sol 阅读签名摘要的生成细节。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考