在传统的 ERC20 代币交互中,用户如果想让第三方合约使用自己的代币,需要先调用 approve 函数进行授权,这会消耗 Gas 费用。ERC20 Permit 标准(EIP-2612)通过引入链下签名机制,允许用户使用签名来授权代币使用权,从而实现"无 Gas"授权。
ERC20 Permit 是 ERC20 的扩展标准,它添加了一个 permit 函数,允许用户通过签名来批准代币支出,而不需要发送交易。
传统方式(两笔交易):
// 交易 1:用户授权(消耗 Gas)
token.approve(spender, amount);
// 交易 2:spender 使用授权
token.transferFrom(user, recipient, amount);
Permit 方式(一笔交易):
// 链下:用户创建签名(不消耗 Gas)
const signature = await user.signPermit(...);
// 链上:spender 使用签名完成授权和转账
token.permit(user, spender, amount, deadline, v, r, s);
token.transferFrom(user, recipient, amount);

使用 Permit 后,用户无需单独为授权支付 Gas,还将两笔交易减少到一笔。
ERC20 Permit 背后的核心机制是 链下签名 + 链上验证:
在以太坊中,每笔交易都需要用私钥签名,网络通过密码学验证来识别 msg.sender。Permit 利用了同样的原理,但将签名用于授权而不是交易本身。
传统的 approve 函数:
// 传统的 approve 函数
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
而 permit 函数与 approve 功能相同,但通过签名验证来实现授权:
// permit 函数:通过签名授权(EIP-2612 标准)
function permit(
address owner, // 代币持有者
address spender, // 被授权者
uint256 value, // 授权额度
uint256 deadline, // 签名过期时间
uint8 v, // 签名参数
bytes32 r, // 签名参数
bytes32 s // 签名参数
) external {
// 验证签名和过期时间...
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
关键区别:
approve:通过 msg.sender 直接授权,需要用户支付 Gaspermit:通过签名验证 owner 身份,任何人都可以提交签名(代付 Gas)本质上,Permit 是一种 授权委托模式:用户创建授权签名,其他人可以使用这个签名来执行授权操作。
Permit 基于 EIP-712 结构化数据签名,EIP-712 定义了一种标准化的方式来对结构化数据进行签名。
深入了解 EIP-712: https://learnblockchain.cn/article/22662
EIP-2612 将 EIP-712 应用到 ERC20 代币上,定义了标准的 permit 函数接口。
接口定义:
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);
function DOMAIN_SEPARATOR() external view returns (bytes32);
}
历史背景:
Permit 最早由 MakerDAO 的 Dai 稳定币实现,后来被标准化为 EIP-2612。现在推荐使用 OpenZeppelin 的 ERC20Permit 实现,它完全符合 EIP-2612 标准。
让我们深入了解 Permit 的实现细节。一个完整的 Permit 实现包含以下核心组件:
DOMAIN_SEPARATOR:域分隔符,唯一标识合约PERMIT_TYPEHASH:许可类型哈希,标识函数签名nonces:防重放攻击的计数器permit 函数:验证签名并执行授权DOMAIN_SEPARATOR 是一个唯一标识智能合约的哈希值,确保签名只在特定合约上有效。
// EIP-712 域分隔符
bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
uint256 private immutable _CACHED_CHAIN_ID;
address private immutable _CACHED_THIS;
bytes32 private immutable _HASHED_NAME;
bytes32 private immutable _HASHED_VERSION;
bytes32 private immutable _TYPE_HASH;
constructor(string memory name, string memory version) {
bytes32 hashedName = keccak256(bytes(name));
bytes32 hashedVersion = keccak256(bytes(version));
bytes32 typeHash = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
_HASHED_NAME = hashedName;
_HASHED_VERSION = hashedVersion;
_CACHED_CHAIN_ID = block.chainid;
_CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion);
_CACHED_THIS = address(this);
_TYPE_HASH = typeHash;
}
function DOMAIN_SEPARATOR() public view returns (bytes32) {
if (address(this) == _CACHED_THIS && block.chainid == _CACHED_CHAIN_ID) {
return _CACHED_DOMAIN_SEPARATOR;
} else {
return _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION);
}
}
function _buildDomainSeparator(
bytes32 typeHash,
bytes32 nameHash,
bytes32 versionHash
) private view returns (bytes32) {
return keccak256(abi.encode(typeHash, nameHash, versionHash, block.chainid, address(this)));
}
组成要素:
name:代币名称version:合约版本(通常为 "1")chainId:链 ID(防止跨链重放)verifyingContract:合约地址作用:
PERMIT_TYPEHASH 是函数签名的哈希值,明确标识签名用于哪个函数。
// EIP-2612 标准 Permit 类型哈希
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
作用:
PERMIT_TYPEHASH 不匹配,交易会回滚nonces 映射记录每个地址已使用的签名次数,防止签名被重复使用。
mapping(address => uint256) public nonces;
工作机制:
nonce 值permit 时,合约验证提供的 nonce 是否与链上记录匹配nonce 自动递增三重防护:
通过 DOMAIN_SEPARATOR、PERMIT_TYPEHASH 和 nonce 三个要素,确保签名:
DOMAIN_SEPARATOR)PERMIT_TYPEHASH)nonce)permit 函数是整个机制的核心,它验证签名并执行授权。
// EIP-2612 标准 permit 函数
function permit(
address owner, // 代币持有者
address spender, // 被授权使用代币的地址
uint256 value, // 授权额度
uint256 deadline, // 签名过期时间戳
uint8 v, // 签名的 v 值
bytes32 r, // 签名的 r 值
bytes32 s // 签名的 s 值
) external;
参数说明:
你可能会疑惑:为什么签名时需要包含这些参数,验证时又要传一遍?这是因为:
首先检查签名是否在有效期内:
// 检查签名是否过期
require(block.timestamp <= deadline, "ERC20Permit: expired deadline");
说明:
deadline 时间戳防止过期签名被使用获取并递增用户的 nonce:
// 获取当前 nonce 并递增
uint256 currentNonce = nonces[owner]++;
工作机制:
nonce 值nonce 加 1计算消息摘要(digest),必须与用户在链下签名时计算的完全一致:
// 计算 EIP-712 结构化数据哈希
bytes32 structHash = keccak256(
abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
currentNonce,
deadline
)
);
// 计算最终的消息摘要
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01", // EIP-191 前缀
DOMAIN_SEPARATOR(), // 域分隔符
structHash // 结构化数据哈希
)
);
关键点:
\x19\x01:EIP-191 标准前缀,防止签名被用作交易使用 ecrecover 从签名中恢复地址,并验证是否为 owner:
// 从签名恢复地址
address signer = ecrecover(digest, v, r, s);
// 验证签名者
require(signer != address(0), "ERC20Permit: invalid signature");
require(signer == owner, "ERC20Permit: invalid signature");
工作原理:
DOMAIN_SEPARATOR 中的 chainId)不匹配都会导致验证失败调试提示⚠️: 签名验证失败时,所有错误都会显示相同的错误信息,这使得调试变得困难。需要仔细检查:
chainId 是否正确nonce 是否是最新的DOMAIN_SEPARATOR 是否匹配所有检查通过后,更新 allowance 并触发事件:
// 执行授权
_approve(owner, spender, value);
内部 _approve 函数:
function _approve(address owner, address spender, uint256 amount) internal {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
allowance[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
优势:
_approve 逻辑,保持一致性现在让我们看看如何在链下创建 Permit 签名。推荐使用现代化的 viem 库,它提供了更简洁、类型安全的 API。
Viem 提供了内置的 EIP-712 签名支持,可以非常方便地创建 Permit 签名。
import { createWalletClient, createPublicClient, http, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { mainnet } from 'viem/chains';
// ERC20 Permit ABI(只需要用到的函数)
const permitABI = [
{
name: 'permit',
type: 'function',
inputs: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
{ name: 'v', type: 'uint8' },
{ name: 'r', type: 'bytes32' },
{ name: 's', type: 'bytes32' },
],
},
{
name: 'nonces',
type: 'function',
inputs: [{ name: 'owner', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }],
},
{
name: 'name',
type: 'function',
inputs: [],
outputs: [{ name: '', type: 'string' }],
},
] as const;
async function createPermitSignature() {
// 1. 创建账户(user1 - 签名者)
const user1Account = privateKeyToAccount('0x...' as `0x${string}`);
// 2. 创建客户端
const publicClient = createPublicClient({
chain: mainnet,
transport: http(),
});
const walletClient = createWalletClient({
account: user1Account,
chain: mainnet,
transport: http(),
});
// 3. 合约信息
const tokenAddress = '0x...' as `0x${string}`;
const spenderAddress = '0x...' as `0x${string}`;
const amount = parseEther('100'); // 授权 100 个代币
// 4. 读取代币名称和当前 nonce
const [name, nonce] = await Promise.all([
publicClient.readContract({
address: tokenAddress,
abi: permitABI,
functionName: 'name',
}),
publicClient.readContract({
address: tokenAddress,
abi: permitABI,
functionName: 'nonces',
args: [user1Account.address],
}),
]);
// 5. 设置过期时间(当前时间 + 1 小时)
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
// 6. 使用 signTypedData 创建 EIP-712 签名
const signature = await walletClient.signTypedData({
account: user1Account,
domain: {
name: name,
version: '1',
chainId: mainnet.id,
verifyingContract: tokenAddress,
},
types: {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
primaryType: 'Permit',
message: {
owner: user1Account.address,
spender: spenderAddress,
value: amount,
nonce: nonce,
deadline: deadline,
},
});
// 7. 分解签名为 v, r, s
const r = signature.slice(0, 66) as `0x${string}`;
const s = `0x${signature.slice(66, 130)}` as `0x${string}`;
const v = parseInt(signature.slice(130, 132), 16);
console.log('签名完成!');
console.log('Signature:', signature);
console.log('v:', v);
console.log('r:', r);
console.log('s:', s);
return {
owner: user1Account.address,
spender: spenderAddress,
value: amount,
deadline: deadline,
v,
r,
s,
};
}
// 使用示例
createPermitSignature().then(console.log);
import { createWalletClient, createPublicClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { mainnet } from 'viem/chains';
async function executePermit(permitData: {
owner: `0x${string}`;
spender: `0x${string}`;
value: bigint;
deadline: bigint;
v: number;
r: `0x${string}`;
s: `0x${string}`;
}) {
// user2 账户(支付 Gas 的人)
const user2Account = privateKeyToAccount('0x...' as `0x${string}`);
const publicClient = createPublicClient({
chain: mainnet,
transport: http(),
});
const walletClient = createWalletClient({
account: user2Account,
chain: mainnet,
transport: http(),
});
const tokenAddress = '0x...' as `0x${string}`;
// 调用 permit 函数(由 user2 支付 Gas)
const hash = await walletClient.writeContract({
address: tokenAddress,
abi: permitABI,
functionName: 'permit',
args: [
permitData.owner,
permitData.spender,
permitData.value,
permitData.deadline,
permitData.v,
permitData.r,
permitData.s,
],
});
console.log('Permit 交易已提交:', hash);
// 等待交易确认
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log('Permit 已确认:', receipt.status);
return receipt;
}
关键点:
signTypedData 创建签名(链下,免费)permit(链上,支付 Gas)如果你在前端使用,可以配合用户的钱包(如 MetaMask):
import { createWalletClient, createPublicClient, custom, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
async function requestPermitSignature() {
// 连接用户的钱包
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum),
});
const [address] = await walletClient.getAddresses();
const tokenAddress = '0x...' as `0x${string}`;
const spenderAddress = '0x...' as `0x${string}`;
// 读取 nonce
const publicClient = createPublicClient({
chain: mainnet,
transport: custom(window.ethereum),
});
const nonce = await publicClient.readContract({
address: tokenAddress,
abi: permitABI,
functionName: 'nonces',
args: [address],
});
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
// 请求用户签名
const signature = await walletClient.signTypedData({
account: address,
domain: {
name: 'MyToken',
version: '1',
chainId: mainnet.id,
verifyingContract: tokenAddress,
},
types: {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
primaryType: 'Permit',
message: {
owner: address,
spender: spenderAddress,
value: parseEther('100'),
nonce: nonce,
deadline: deadline,
},
});
// 返回签名数据
return {
signature,
v: parseInt(signature.slice(130, 132), 16),
r: signature.slice(0, 66) as `0x${string}`,
s: `0x${signature.slice(66, 130)}` as `0x${string}`,
};
}
如果你想实现自己的支持 Permit 的 ERC20 代币,推荐使用 OpenZeppelin 的实现:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract MyToken is ERC20, ERC20Permit {
constructor() ERC20("MyToken", "MTK") ERC20Permit("MyToken") {
_mint(msg.sender, 1000000 * 10**decimals());
}
}
就这么简单! ERC20Permit 扩展会自动为你的代币添加:
permit 函数nonces 映射DOMAIN_SEPARATOR 计算// 传统方式:用户需要两笔交易
// 交易 1:approve
token.approve(dex, amount);
// 交易 2:swap
dex.swap(token, amount);
// 使用 Permit:只需一笔交易
// 用户在前端签名(免费)
// 在合约封装包装两个调用:
token.permit(user, dex, amount, deadline, v, r, s);
dex.swap(token, amount);
允许新用户无需持有 ETH 就能使用 DApp:
contract GaslessTransfer {
function transferWithPermit(
IERC20Permit token,
address from,
address to,
uint256 amount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
// 使用用户的签名授权
token.permit(from, address(this), amount, deadline, v, r, s);
// 执行转账
token.transferFrom(from, to, amount);
}
}
用户只需要签名,Gas 费用由服务提供方承担。
结合 Multicall 实现更复杂的操作:
// 在一笔交易中完成:授权 + 质押 + 领取奖励
function permitAndStake(
uint256 amount,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
// 1. 使用 permit 授权
token.permit(msg.sender, address(this), amount, deadline, v, r, s);
// 2. 质押代币
token.transferFrom(msg.sender, address(this), amount);
stakes[msg.sender] += amount;
// 3. 领取之前的奖励
_claimRewards(msg.sender);
}
ERC20 Permit 通过引入链下签名机制,为代币授权带来了革命性的改进:
推荐所有的新发行代币使用 ERC20 Permit,利用 Permit 机制,能够构建更友好、更高效的代币交互体验!
参考资源: