Please don't OSINT me, I'd be shy. 🫣
本文適合已了解 ERC-20 基礎(transfer、approve、transferFrom)以及 ERC-2612(permit)的開發者。
📌 概述
Permit2 不是一個 ERC 標準,它是一個部署於鏈上的智能合約。由 Uniswap Labs 所開發,原本是為了統一 Uniswap 生態系內的授權邏輯而生,現在已被廣泛採納成為 DeFi 基礎設施。
傳統 ERC-20 approve 的問題
傳統流程中,使用者每次使用一個新 DApp 都必須先發送一筆 approve 交易,授權 DApp 花費自己的 ERC-20 代幣。這不僅消耗 gas,也製造了大量「無限授權」的安全隱患。
一般來說,DApp 開發商考量到使用者體驗與 Gas Fee,會請求使用者在初次使用 DApp 時就一勞永逸地授權
type(uint256).max。
如果 DApp 被攻擊者找到漏洞(例如:Arbitrary Code Execution),攻擊者就能直接透過 transferFrom 把 DApp Users 的 ERC-20 代幣全部轉走。
ERC-2612 的限制
ERC-2612(permit)解決了兩步驟交易與無限授權的問題,但對於需要整合大量野外 ERC-20 代幣的 DEX 協議(例如 Uniswap)來說,並非所有代幣都支援 ERC-2612。Uniswap 無法假設每個上架的代幣都有 permit() 函數。
Permit2 的切入點
Permit2 被設計用來當作資金調度代理人:
使用者只需要 approve 給 Permit2 合約一次無限額度(只需一筆交易)
未來與任何整合了 Permit2 的 DApp 互動時,Permit2 可以代為執行
transferFrom把代幣調度給 DApp
🧠 概念類比
ERC-2612:職業駕照制度
今天你喝了一點酒,沒辦法開車回家,需要找人代駕。你需要找一個有職業駕照的人來開你的車(permit),普通自小客車駕照(approve)不夠格。
限制:對方必須具備「職業駕照」,也就是代幣本身必須實作 ERC-2612。
Permit2:授權給家中的退休長輩
你住在台北,老家在高雄。有一天需要收老家的掛號信,需要個人印鑑才能簽收,但你沒辦法親自南下。
於是你授權已經退休、閒著無事的媽媽(Permit2 合約)可以使用你的印鑑和郵差或相關機構互動。
具體來說,你在電話中跟媽媽說:「下午郵差會來找我,我的印鑑放在抽屜,幫我收個信。」
第二天,你還需要到位於高雄的戶政事務所辦事(DApp2),但你還是沒辦法親自南下。好在你已經授權媽媽可以使用你的印鑑了。媽媽不需要再次被授權就能直接幫你處理。
重點:媽媽只需要一開始被你授權一次,之後任何需要印鑑的場合她都能代理。Permit2 合約就是那個「代理人」。
好吧,我承認這 ERC-2612 的類比有點糟糕。我希望讀者能夠理解 Permit2 作為一個「授權代理人」的概念。
📍 架構解析
Permit2 合約程式碼本身是空的,但它繼承了兩份合約:
Permit2
├── SignatureTransfer.sol ── 簽名式轉帳(不需要預先 approve)
└── AllowanceTransfer.sol ── 授權額度管理(支援簽名 + 直接設定)
SignatureTransfer — 簽名式轉帳
使用者對一筆「轉帳意圖」簽名,簽名跟著交易一起送進區塊鏈。簽名有效期間僅限該筆交易,交易結束就失效。
類似於現實世界中的支票:簽名 → 票據交付 → 銀行核驗 → 轉帳。
適用場景: 用戶不需要先 approve,直接簽名就能轉帳。任何 ERC-20 代幣都能用,包括沒有實作 ERC-2612 的老代幣。
function permitTransferFrom(
PermitTransferFrom memory permit,// 權限描述(代幣、數量、nonce、deadline)
SignatureTransferDetails calldata transferDetails, // 轉帳目標與金額
address owner, // 簽署者 = 代幣持有者
bytes calldata signature // 使用者簽名
) external
Source: https://github.com/Uniswap/permit2/blob/cc56ad0/src/SignatureTransfer.sol#L22-L27
permitTransferFrom 參數內部結構
struct PermitTransferFrom {
TokenPermissions permitted; // {token: address, amount: uint256}
uint256 nonce;
uint256 deadline;
}struct SignatureTransferDetails {
address to;
uint256 requestedAmount;
}
兩份 struct 在用戶簽署時,還不需要配在一起,是在交易廣播前才組合成完整指令送進 Permit2 合約。
AllowanceTransfer — 授權額度管理
回到了傳統的 ERC-20 approval 模式,但多了以下功能:
限時授權:授權人可以設定 deadline,到期後自動失效。
離線授權:不必發出 approve 交易上鏈,鏈下簽名即可設定授權額度。
批次授權:一筆簽名可以對多個代幣、多個 Spender 設定額度。
批次撤銷:一筆交易可以 Revoke 多組授權。
function permit(
address owner,
PermitSingle memory permitSingle,
bytes calldata signature
) external
Source: https://github.com/Uniswap/permit2/blob/cc56ad0/src/AllowanceTransfer.sol#L33
PermitSingle 內部結構
struct PermitSingle {
PermitDetails details;
address spender;
uint256 sigDeadline; // 這個簽名本身能被提交多久?(較短)
}struct PermitDetails {
address token;
uint160 amount;
uint48 expiration; // 這筆 allowance 能被使用多久?(較長)
uint48 nonce;
}
以 Gas 效率來說,AllowanceTransfer 涉及較多的狀態變更、授權也更持久,因此會比 SignatureTransfer 多花費一些 Gas。
😈 使用 Permit2 有什麼安全風險嗎?
現在我們知道 Permit2 是一個「被授權的資金調度代理人」是一個「智能合約」,那麼在實務使用上,有哪些需要注意的地方呢?
Permit2 合約存在單點故障風險
既然使用者只需要 approve 給 Permit2 一次,原則上攻擊者只需要設法攻破 Permit2 這一個合約,就能調動所有用戶的代幣。這與傳統上每個 DApp 各自保留一份授權的模式相比,是一個單點故障(Single Point of Failure)。
這就像你把家裡全部鑰匙都交給大樓管理員。管理員本身誠信沒問題不會作惡,但管理員被搶了,所有人都 BBQ 了。
開發 Permit2 合約的 Uniswap Labs 也知道這個風險,因此 Permit2 合約經過了多次審計,且設計上盡量精簡以減少攻擊面。但這仍然是採用 Permit2 前必須權衡、必須認知到的安全風險。
🔧 開發整合指南
Permit2 SDK 有兩種用法:直接與合約互動,或透過 @uniswap/permit2-sdk 簡化簽名流程。
npm install @uniswap/permit2-sdk
npm install viem
SignatureTransfer
適合:不需要使用者預先 approve 的場景,例如 DEX 交易、跨合約轉帳。
步驟一:產生 PermitTransferFrom 資料結構
const { SignatureTransfer } = require('@uniswap/permit2-sdk');
const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
const chainId = 1;
const permitTransferFrom = {
permitted: {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
amount: 100000000, // 100 USDC
},
spender: "0x1111111111111111111111111111111111111111",
nonce: 123,
deadline: 1774941540, // 2026-03-31T07:19:00Z
};
const { domain, types, values } = SignatureTransfer.getPermitData(
permitTransferFrom, PERMIT2_ADDRESS, chainId
);
console.log('Domain:', domain);
console.log('Types:', types);
console.log('Values:', values);
步驟二:產生簽名
const signature = await walletClient.signTypedData({
domain,
types,
primaryType: 'PermitTransferFrom',
message: values,
});
console.log('Signature:', signature);
// Output: 0x... (65 bytes)
步驟三:提交至 DApp
const { encodeFunctionData } = require('viem');
const permit2Abi = [{
inputs: [
{ components: [{ components: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }], name: 'permitted', type: 'tuple' }, { name: 'spender', type: 'address' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }], name: 'permit', type: 'tuple' },
{ components: [{ name: 'to', type: 'address' }, { name: 'requestedAmount', type: 'uint256' }], name: 'transferDetails', type: 'tuple' },
{ name: 'owner', type: 'address' },
{ name: 'signature', type: 'bytes' },
],
name: 'permitTransferFrom',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
}];
const calldata = encodeFunctionData({
abi: permit2Abi,
functionName: 'permitTransferFrom',
args: [
{ permitted: permitTransferFrom.permitted, spender: permitTransferFrom.spender, nonce: permitTransferFrom.nonce, deadline: permitTransferFrom.deadline },
{ to: "0x2222222222222222222222222222222222222222", requestedAmount: permitTransferFrom.permitted.amount },
"0x3333333333333333333333333333333333333333",
signature,
],
});
console.log('Calldata:', calldata);
// 發送交易
const txHash = await walletClient.sendTransaction({
to: PERMIT2_ADDRESS,
data: calldata,
});
AllowanceTransfer
適合:需要長期或批次授權的場景,例如質押、收益聚合器。
步驟一:產生 permitSingle
const { AllowanceTransfer } = require('@uniswap/permit2-sdk');
const permitSingle = {
details: {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
amount: 1000000000, // 1000 USDC
expiration: 1777528271, // 2026-04-30T05:51:11Z
nonce: 0,
},
spender: "0x1111111111111111111111111111111111111111",
sigDeadline: 1774945140, // 2026-03-31T08:19:00Z
};
const { domain, types, values } = AllowanceTransfer.getPermitData(
permitSingle, PERMIT2_ADDRESS, chainId
);
console.log('Values:', values);
步驟二:產生簽名
const signature = await walletClient.signTypedData({
domain,
types,
primaryType: 'PermitSingle',
message: values,
});
console.log('Signature:', signature);
步驟三:提交至 DApp
// 第一階段:設置授權
const permit2Abi = [
{
inputs: [
{ name: 'owner', type: 'address' },
{ components: [{ components: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint160' }, { name: 'expiration', type: 'uint48' }, { name: 'nonce', type: 'uint48' }], name: 'details', type: 'tuple' }, { name: 'spender', type: 'address' }, { name: 'sigDeadline', type: 'uint256' }], name: 'permitSingle', type: 'tuple' },
{ name: 'signature', type: 'bytes' },
],
name: 'permit',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
{
inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'amount', type: 'uint160' }, { name: 'token', type: 'address' }],
name: 'transferFrom',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
];
const permitCalldata = encodeFunctionData({
abi: permit2Abi,
functionName: 'permit',
args: ["0x3333333333333333333333333333333333333333", permitSingle, signature],
});
console.log('Permit Calldata:', permitCalldata);
// 第二階段:使用授權轉帳
const transferCalldata = encodeFunctionData({
abi: permit2Abi,
functionName: 'transferFrom',
args: [
"0x3333333333333333333333333333333333333333",
"0x2222222222222222222222222222222222222222",
100000000,
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
],
});
console.log('Transfer Calldata:', transferCalldata);
重要提醒
預先授權 Permit2
在使用 Permit2 之前,使用者必須先對 Permit2 合約進行無限授權(只需一次):
await walletClient.writeContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: erc20Abi,
functionName: 'approve',
args: [PERMIT2_ADDRESS, "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"],
});
Nonce 管理
SignatureTransfer 可以使用任意無序數字作為 Nonce,只要不重複即可
AllowanceTransfer 的 nonce 會根據 owner/token/spender 組合遞增,需要從 Permit2 合約查詢當前 nonce。

