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

ERC-20 in depth: allowances, approvals, and the bugs in between

transfer is the easy half of ERC-20. The hard, money-moving half is the approve/transferFrom allowance flow — the engine behind every DeFi swap, and the source of its most famous footguns.

Why approval exists: tokens that wait to be pulled

Picture a vending machine behind a glass wall. You can see it, but there's no coin slot on your side — you can't hand it money. An ERC-20 (a fungible token) `transfer` is like dropping coins into your own pocket: it moves tokens to an address, but if the recipient is a contract, it is never told the payment happened. The plain standard has no "you just got paid" callback. So how does a Uniswap router or an Aave market ever pull your tokens in to do something with them?

The answer is a two-step pull model. First you call `approve(spender, amount)`, which sets an allowance — a promise that says "this spender may move up to amount of my tokens." Then you call a function on the spender contract, and it calls `transferFrom(you, itself, amount)`, drawing on that allowance. It is the on-chain version of a standing order at your bank: you authorize a merchant once, and they pull the agreed amount when it comes due. Almost every DeFi interaction — swapping, depositing, staking, repaying — is built on this dance.

Reading the standard: transfer, approve, transferFrom

The whole token standard is six functions and two events. Three of them — `allowance`, `approve`, `transferFrom` — exist only to support the pull model. (`transfer`, by contrast, is the simple self-service path: you move your own tokens.)

// 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);
}
The canonical IERC20 interface.

The allowance — also called a token approval — lives in a two-level mapping: `allowance[owner][spender]`. When the spender calls `transferFrom`, the contract checks that allowance, decrements it, and moves the balance. Read the implementation closely — three details bite people.

// 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;
}
A minimal, correct approve + transferFrom (Solidity ^0.8).

First, inside `transferFrom`, `msg.sender` is the spender, not the token owner — the owner authorized this earlier via `approve`. Second, each pull decrements the allowance, so an approval is a budget that drains as it is used. Third, the `type(uint256).max` shortcut: an infinite approval is never decremented, which saves a costly storage write but means the spender's permission never runs down on its own — a fact that matters a lot two sections from now.

The approve race condition

In 2017, researchers Mikhail Vladimirov and Dmitry Khovratovich described a trap baked into `approve` that is still documented in the EIP-20 spec itself. The problem: changing an existing allowance is not atomic. Suppose you have already approved a spender for 100 tokens and now want to lower it to 50.

  1. Starting point: you have approved the spender for 100 tokens (`allowance = 100`).
  2. You change your mind and submit `approve(spender, 50)`. The transaction sits in the mempool, waiting to be mined.
  3. A malicious spender watches the mempool and gets ahead of you with a front-running transaction, calling `transferFrom` to pull the old 100 tokens before your change lands.
  4. Your `approve(50)` now confirms, resetting the allowance to 50.
  5. The spender calls `transferFrom` again, pulling another 50. It moved 150 tokens total — far past the limit you ever intended.

There are two fixes. The most robust is zero-then-set: call `approve(spender, 0)`, wait for it to confirm, then `approve(spender, 50)` — leaving no old allowance to front-run. The other is the `increaseAllowance` / `decreaseAllowance` pair, which adjust by a delta instead of overwriting.

// 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
Delta-based change, and the manual zero-then-set pattern.

Honesty check: OpenZeppelin removed `increaseAllowance` and `decreaseAllowance` in v5.0 (2024), arguing they did not truly fix the underlying problem and widened the surface for misuse. The modern guidance is to use `permit` signatures (next section) or simply approve the exact amount each time. The race itself requires a spender that is both malicious and watching the mempool, so its practical risk is debated — but it is the classic lesson that fuzzy interface semantics cause real losses.

EIP-2612 permit: approval by signature

The pull model has an annoying cost: every fresh interaction usually needs two transactions — `approve`, then the real action — so the user pays gas twice and must already hold the native coin to afford the first one. "Why do I have to confirm twice just to swap?" is a real reason newcomers bounce.

EIP-2612 fixes this with a `permit` function. You use your private key to sign a structured message off-chain (following the EIP-712 typed-data scheme) that names the owner, spender, value, nonce, and a deadline. The signature costs no gas and touches no chain. Then anyone — usually the dApp's router or a relayer paying gas for you — bundles that signature with the action into a single transaction: call `permit` to set the allowance, then immediately `transferFrom` to use it.

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);
}
An EIP-2612 permit: an allowance set from an off-chain signature.
  1. Alice signs the Permit struct off-chain (no gas, no transaction), producing the values `(value, deadline, v, r, s)`.
  2. She hands those values, plus her swap intent, to the dApp or router.
  3. In one transaction, the router calls `token.permit(...)` to set the allowance, then immediately `token.transferFrom(...)` to draw on it — Alice signed once and clicked once.

Infinite approval: convenience versus catastrophe

You have probably seen a wallet pop up an "approve unlimited" request. dApps default to requesting `type(uint256).max` (effectively infinite) for UX: approve once, and you never have to `approve` again no matter how often you trade in that protocol. For a heavy user, that is convenient.

The cost is that the allowance stays there. If the spender contract has a bug, gets upgraded to a malicious version, or you signed the approval on a phishing site, the spender can drain that token's entire balance at any later moment — no private key needed, because you already authorized it. A huge share of "wallet drainer" phishing works exactly this way: trick the user into one `approve` or `permit`, then quietly pull the funds afterward. Infinite approvals are silent, lingering risk.

  1. Approve exact amounts. Authorize only what this interaction needs; the allowance returns to zero on its own. `permit` makes doing this every time painless.
  2. Revoke periodically. Use Etherscan's Token Approvals page or a tool like revoke.cash to set stale allowances back to 0.
  3. Use expiring allowances. Mechanisms like Permit2 attach an expiry time to an allowance, so it lapses automatically and you avoid the "infinite and permanent" combination.

Tokens that break the rules

Real mastery is knowing how many deployed tokens quietly do not follow the standard — and that every deviation is a landmine for whoever integrates them. The interface above is the ideal; production is messier.

  1. No return value. The famous case is USDT (Tether): its `transfer` and `approve` return nothing, so calling them through a strict `IERC20` can revert on the missing `bool`.
  2. Fee-on-transfer. Some tokens skim a cut on every move, so the amount received is less than the amount sent. A contract that assumes "received == the amount argument" mis-accounts.
  3. Rebasing. Tokens like stETH or AMPL change balances without emitting a `Transfer`, so any number you cached drifts out of date.
  4. USDT's approve guard. USDT enforces on-chain that you must reset a nonzero allowance to 0 before changing it to a new nonzero value, or the `approve` reverts — hard-coding the zero-then-set workaround into the token itself.
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's SafeERC20 wrapper absorbs these quirks.

So professional integrators never call tokens raw — they go through OpenZeppelin's `SafeERC20`: `safeTransfer` / `safeTransferFrom` swallow the missing-return-value quirk, and `forceApprove` handles the USDT zero-first rule. In a world where a single AMM or lending protocol can lock up billions of dollars, defending against every token as if it might misbehave is not paranoia — it is the baseline. ERC-20 looks simple; its allowance machinery is where the real engineering, and the real risk, live.