Skip to main content

Command Palette

Search for a command to run...

Permit2 介紹與開發整合指南

從原理到實作,一次搞懂 Permit2 模式

Updated
•5 min read•View as Markdown
W

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 被設計用來當作資金調度代理人:

  1. 使用者只需要 approve 給 Permit2 合約一次無限額度(只需一筆交易)

  2. 未來與任何整合了 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;
}

Source: https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/interfaces/ISignatureTransfer.sol#L21-L36

兩份 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。

17 views