JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

深入 ERC-20:授權額度、批准,以及其間的漏洞

transfer 只是 ERC-20 簡單的那一半。真正會搬動金錢、也最容易出事的另一半,是 approve/transferFrom 這套授權機制——它是每一筆 DeFi 兌換背後的引擎,也是最著名漏洞的溫床。

為什麼需要授權:等著被「拉走」的代幣

想像一台隔著玻璃牆的自動販賣機。你看得到它,但你這側沒有投幣孔——你沒辦法把錢交給它。一筆 ERC-20(一種同質化代幣)的 `transfer` 就像把硬幣放進自己口袋:它把代幣移到某個位址,但若接收端是一份合約,它完全不會被告知這筆款項發生過。原始標準沒有「你剛收到錢」的回呼。那麼,Uniswap 的 router 或 Aave 的市場,究竟是怎麼把你的代幣「拉」進去動用的?

答案是一套兩步驟的拉取模型(pull model)。你先呼叫 `approve(spender, amount)`,設定一筆 授權額度——一個承諾,意思是「這個支用者最多可以動用我 amount 這麼多代幣」。接著你呼叫支用者合約上的某個函式,由去呼叫 `transferFrom(你, 它自己, amount)`,從那筆額度裡扣款。這是銀行約定扣款(standing order)的鏈上版本:你授權商家一次,之後到期時由他們來拉款。幾乎每一次 DeFi 互動——兌換、存款、質押、還款——都建立在這支舞步之上。

讀懂標準:transfer、approve 與 transferFrom

整套代幣標準不過是六個函式加兩個事件。其中三個——`allowance`、`approve`、`transferFrom`——存在的唯一目的就是支撐拉取模型。(相對地,`transfer` 是簡單的自助路徑:你搬動自己的代幣。)

// The complete ERC-20 surface: 6 functions, 2 events.
interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);

    // Direct, self-service transfer (push).
    function transfer(address to, uint256 amount) external returns (bool);

    // The allowance trio that powers the pull model:
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}
標準的 IERC20 介面。

授權額度——也叫代幣批准(token approval)——存在一個雙層 映射裡:`allowance[擁有者][支用者]`。當支用者呼叫 `transferFrom` 時,合約會檢查這筆額度、把它扣掉,再搬動餘額。仔細讀這段實作——有三個細節最常咬人。

// balances and the two-level allowance map
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

function approve(address spender, uint256 amount) public returns (bool) {
    allowance[msg.sender][spender] = amount;          // overwrites any prior value
    emit Approval(msg.sender, spender, amount);
    return true;
}

function transferFrom(address from, address to, uint256 amount) public returns (bool) {
    // msg.sender here is the SPENDER, not the token owner.
    uint256 allowed = allowance[from][msg.sender];
    require(allowed >= amount, "insufficient allowance");

    // Gas optimization: an infinite (max) approval is never decremented,
    // saving an expensive storage write on every pull.
    if (allowed != type(uint256).max) {
        allowance[from][msg.sender] = allowed - amount;   // 0.8 reverts on underflow
    }

    balanceOf[from] -= amount;   // reverts if 'from' is short (Solidity 0.8 checked math)
    balanceOf[to]   += amount;
    emit Transfer(from, to, amount);
    return true;
}
一段最小且正確的 approve + transferFrom(Solidity ^0.8)。

第一,在 `transferFrom` 內,`msg.sender` 是支用者,而非代幣擁有者——擁有者早先已透過 `approve` 授權。第二,每拉取一次就會扣減額度,所以一筆授權像是一筆會隨使用而見底的預算。第三,那個 `type(uint256).max` 的捷徑:無限授權永遠不會被扣減,省下一次昂貴的 storage 寫入,但也意味著支用者的權限不會自己用完——這件事在兩節之後會變得非常重要。

approve 的競態條件

2017 年,研究者 Mikhail Vladimirov 與 Dmitry Khovratovich 描述了一個藏在 `approve` 裡、至今仍寫在 EIP-20 規格中的陷阱。問題在於:改變一筆既有授權額度並不是一個原子動作。假設你已授權某個支用者 100 顆代幣,現在想把它調降到 50。

  1. 起點:你已授權支用者 100 顆代幣(`allowance = 100`)。
  2. 你改變心意,送出 `approve(spender, 50)`。這筆交易躺在記憶池中,等著被打包。
  3. 惡意的支用者監看記憶池,搶在你之前發動一筆搶先交易,呼叫 `transferFrom` 在你的更動生效前先把舊的 100 顆全數拉走。
  4. 你的 `approve(50)` 接著確認,把額度重設為 50。
  5. 支用者再呼叫一次 `transferFrom`,又拉走 50 顆。它總共動用了 150 顆——遠超過你曾打算授權的上限。

修正方式有兩種。最穩妥的是先歸零、再設定:先呼叫 `approve(spender, 0)`,等它確認後,再 `approve(spender, 50)`——這樣中間沒有可被搶跑的舊額度。另一種是用 `increaseAllowance` / `decreaseAllowance` 這對函式,以差量調整取代直接覆寫。

// Differential changes avoid overwriting a live allowance (no race window).
function increaseAllowance(address spender, uint256 added) public returns (bool) {
    allowance[msg.sender][spender] += added;
    emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
    return true;
}

// The safest manual fix from a wallet: zero it out first, THEN set the new value.
//   token.approve(spender, 0);     // tx 1, confirm
//   token.approve(spender, 50);    // tx 2 — no old allowance left to front-run
以差量改變額度,以及手動的「先歸零、再設定」模式。

誠實提醒:OpenZeppelin 在 v5.0(2024)中移除了 `increaseAllowance` 與 `decreaseAllowance`,理由是它們並未真正根除底層問題,反而擴大了誤用空間。今日的建議是改用 `permit` 簽章(下一節),或乾脆每次只授權確切金額。這個競態本身需要支用者既惡意又盯著記憶池,實務風險仍有爭議——但它是「介面語意含糊就會釀成真實損失」的經典教材。

EIP-2612 permit:用簽章完成授權

拉取模型有個惱人的代價:每一次新互動通常得送兩筆交易——先 `approve`,再做真正的動作——使用者要付兩次 gas,還得先持有原生代幣才付得起第一筆。「為什麼我換個幣要按兩次確認」正是勸退新手的一大原因。

EIP-2612 用一個 `permit` 函式解決它。你用私鑰在鏈下對一份結構化訊息簽章(遵循 EIP-712 的型別化資料規範),內容指定 owner、spender、value、nonce 與一個 deadline。這份簽章不花 gas、不碰鏈。之後任何人——通常是 dApp 的 router 或替你付 gas 的中繼者——把這份簽章連同動作打包進同一筆交易:先呼叫 `permit` 設定額度,再立刻 `transferFrom` 動用它。

bytes32 public constant PERMIT_TYPEHASH = keccak256(
    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
mapping(address => uint256) public nonces;   // one running counter per owner

// Set an allowance from an OFF-CHAIN signature — no prior transaction from the owner.
function permit(
    address owner, address spender, uint256 value,
    uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external {
    require(block.timestamp <= deadline, "permit expired");

    // EIP-712 typed-data hashing; nonces[owner]++ burns this signature after one use.
    bytes32 structHash = keccak256(
        abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)
    );
    bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));

    address signer = ecrecover(digest, v, r, s);
    require(signer != address(0) && signer == owner, "invalid signature");

    allowance[owner][spender] = value;
    emit Approval(owner, spender, value);
}
一個 EIP-2612 permit:用一份鏈下簽章來設定授權額度。
  1. Alice 在鏈下對 Permit 結構簽章(無 gas、無交易),得到 `(value, deadline, v, r, s)` 這些值。
  2. 她把這些值連同兌換意圖交給 dApp 或 router。
  3. 在同一筆交易中,router 先呼叫 `token.permit(...)` 設定額度,再立刻呼叫 `token.transferFrom(...)` 動用它——Alice 只簽一次、按一次。

無限授權:便利與災難之間

你大概看過錢包跳出「授權無上限」的請求。dApp 之所以預設請求 `type(uint256).max`(約等於無限),是為了體驗:授權一次,之後不論你在這個協定裡交易多少次,都不必再 `approve`。對重度使用者很方便。

代價是那筆額度會一直留著。只要支用者合約有漏洞、被升級成惡意版本,或你當初是在釣魚網站上簽了授權,對方就能在任何時刻把該代幣的整個餘額掏空——不需要你的私鑰,因為你早已授權。大量「錢包抽乾器(drainer)」釣魚正是這樣運作:誘導使用者簽下一筆 `approve` 或 `permit`,事後再從容把資產提走。無限授權是一種無聲、潛伏的風險。

  1. 只授權確切金額。 用多少授權多少,互動後額度自然歸零。`permit` 讓每次都這麼做不再痛苦。
  2. 定期撤銷。 用 Etherscan 的 Token Approvals 頁面,或 revoke.cash 之類工具,把不再使用的舊授權設回 0。
  3. 改用會過期的授權。 Permit2 之類機制讓額度帶有到期時間,逾期自動失效,避開「無限永久」這個組合。

不守規矩的代幣

真正的精通,在於知道有多少已部署的代幣其實悄悄地照標準走——而每一個偏差都是整合者腳下的地雷。上面那個介面是理想;正式環境要混亂得多。

  1. 不回傳值。 最有名的就是 USDT(Tether):它的 `transfer` 與 `approve` 沒有回傳值,因此用嚴格的 `IERC20` 介面去呼叫,會因為少了那個 `bool` 而 revert。
  2. 轉帳收費(fee-on-transfer)。 有些代幣每次轉帳會抽成,收到的數量小於送出的數量。若合約假設「收到的 == 傳入參數」,帳就會記錯。
  3. 變基(rebasing)。 像 stETH 或 AMPL 這類代幣會在不發出 `Transfer` 事件的情況下改變餘額,於是你快取的任何數字都會逐漸失準。
  4. USDT 的 approve 限制。 USDT 在鏈上強制要求:若既有額度非零,你必須先把它設回 0,才能改成新的非零值,否則 `approve` 會 revert——等於把「先歸零」變通法直接寫死進代幣本身。
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20}   from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Vault {
    using SafeERC20 for IERC20;

    function pullDeposit(IERC20 token, address user, uint256 amount) external {
        // safeTransferFrom tolerates tokens (like USDT) that return no bool.
        token.safeTransferFrom(user, address(this), amount);
    }

    function approveRouter(IERC20 token, address router, uint256 amount) external {
        // forceApprove first sets the allowance to 0 if needed,
        // handling USDT's "must zero before changing a nonzero approval" rule.
        token.forceApprove(router, amount);
    }
}
OpenZeppelin 的 SafeERC20 包裝層吸收掉這些怪癖。

因此專業整合者從不直接呼叫代幣——他們一律透過 OpenZeppelin 的 `SafeERC20`:`safeTransfer` / `safeTransferFrom` 會吞下「沒有回傳值」的怪癖,`forceApprove` 會處理 USDT 的「先歸零」要求。在一個AMM借貸協定動輒鎖進數十億美元的世界裡,把每一個代幣都當成可能不守規矩來防禦,不是潔癖,而是底線。ERC-20 看似簡單;它的授權機制,才是真正的工程與真正的風險所在。