standards/erc-token-standards

ERC Token Standards Guide (ERC-20, ERC-777, ERC-4626, ERC-2612)

ethereumstandard-guide🏛️ Officialconfidence highhealth -2%
v1.0.0·Updated 3/26/2026

Overview

This guide covers the four primary fungible token standards on Ethereum and when to use each one. Understanding these standards is essential for any DeFi protocol, wallet, or dApp that handles ERC tokens.

Standards Comparison Table

FeatureERC-20ERC-777ERC-4626ERC-2612
Core purposeFungible tokenAdvanced fungible tokenYield-bearing vault tokenGasless approval extension
StatusFinal (canonical)Final (deprecated in practice)FinalFinal
Reentrancy riskLowHigh (hooks)MediumLow
Gas efficiencyHighLowMediumHigh
Ecosystem supportUniversalLimitedGrowing (DeFi vaults)Widely adopted
ExtendsERC-20 compatibleERC-20ERC-20
Key featureStandard transfersSend hooksShare/asset accountingoff-chain approvals

ERC-20 — The Foundation

ERC-20 is the universal standard for fungible tokens. Use it as your default choice.

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    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);
}

Critical implementation notes:

  • transfer and transferFrom MUST return bool — some tokens (USDT) don't, causing integration failures
  • Always use SafeERC20 (OpenZeppelin) when calling external tokens to handle non-standard returns
  • The approve race condition: always set allowance to 0 before setting a new non-zero value
// Safe pattern for allowance updates
token.approve(spender, 0);
token.approve(spender, newAmount);

// Or use increaseAllowance / decreaseAllowance (OpenZeppelin extension)

Common AI hallucination: ERC-20 does NOT have a mint or burn function in the standard interface — these are implementation details.

ERC-777 — Advanced Hooks (Use with Caution)

ERC-777 adds operator permissions and send/receive hooks. In practice, avoid using ERC-777 for new projects due to reentrancy risks that have caused major exploits (Uniswap v1, imBTC).

// ERC-777 send with data (backward compatible with ERC-20)
IERC777(token).send(recipient, amount, "0x");

// Register as a receiver to get notified
interface IERC777Recipient {
    function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external;
}

When ERC-777 appears: You may encounter it in legacy protocols. Always add reentrancy guards when interacting with ERC-777 tokens.

ERC-4626 — Tokenized Vaults

ERC-4626 standardizes the interface for yield-bearing vaults (like Aave aTokens, Compound cTokens, Yearn vaults). It extends ERC-20 with deposit/withdraw mechanics and share accounting.

interface IERC4626 is IERC20 {
    // Asset management
    function asset() external view returns (address assetTokenAddress);
    function totalAssets() external view returns (uint256 totalManagedAssets);

    // Deposit: assets in → shares out
    function deposit(uint256 assets, address receiver) external returns (uint256 shares);
    function mint(uint256 shares, address receiver) external returns (uint256 assets);

    // Withdraw: shares in → assets out
    function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
    function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);

    // Conversion (may include fees/slippage)
    function convertToShares(uint256 assets) external view returns (uint256 shares);
    function convertToAssets(uint256 shares) external view returns (uint256 assets);

    // Preview (includes fees, may revert if deposit not possible)
    function previewDeposit(uint256 assets) external view returns (uint256 shares);
    function previewMint(uint256 shares) external view returns (uint256 assets);
    function previewWithdraw(uint256 assets) external view returns (uint256 shares);
    function previewRedeem(uint256 shares) external view returns (uint256 assets);
}

Vault Math

The share price formula is:

sharePrice = totalAssets / totalSupply
shares = assets * totalSupply / totalAssets   (deposit)
assets = shares * totalAssets / totalSupply   (redeem)

Inflation attack protection: ERC-4626 vaults are vulnerable to inflation attacks on first deposit. Mitigations:

  1. Virtual shares: initialize with totalSupply = 1e3, totalAssets = 1e3 (OpenZeppelin's approach)
  2. Dead shares: burn initial shares to address(0)
  3. First-deposit minimum: enforce minimum deposit amount
// OpenZeppelin 4626 with virtual shares (recommended)
contract MyVault is ERC4626 {
    constructor(IERC20 asset) ERC4626(asset) ERC20("My Vault", "mvTKN") {}

    function _decimalsOffset() internal pure override returns (uint8) {
        return 3; // 1000x virtual shares protection
    }
}

deposit vs mint, withdraw vs redeem

  • deposit(assets) → you specify how many assets to put in, get back calculated shares
  • mint(shares) → you specify how many shares you want, pay the required assets
  • withdraw(assets) → you specify how many assets you want back, burn calculated shares
  • redeem(shares) → you specify how many shares to burn, get back calculated assets

ERC-2612 — Permit (Gasless Approvals)

ERC-2612 adds a permit function to ERC-20, allowing approvals via off-chain signatures (EIP-712). This eliminates the need for a separate approval transaction, saving gas and improving UX.

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);
}

Creating a Permit Signature (ethers.js v6)

import { ethers } from "ethers";

async function createPermitSignature(
  tokenContract: ethers.Contract,
  signer: ethers.Signer,
  spender: string,
  value: bigint,
  deadline: bigint
) {
  const owner = await signer.getAddress();
  const nonce = await tokenContract.nonces(owner);
  const name = await tokenContract.name();
  const chainId = (await signer.provider!.getNetwork()).chainId;
  const tokenAddress = await tokenContract.getAddress();

  const domain = {
    name,
    version: "1",
    chainId,
    verifyingContract: tokenAddress,
  };

  const types = {
    Permit: [
      { name: "owner", type: "address" },
      { name: "spender", type: "address" },
      { name: "value", type: "uint256" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" },
    ],
  };

  const message = { owner, spender, value, nonce, deadline };

  const signature = await signer.signTypedData(domain, types, message);
  return ethers.Signature.from(signature);
}

// Usage: permitAndTransfer in one tx
const sig = await createPermitSignature(token, signer, spender, amount, deadline);
await contract.permitAndDeposit(token, amount, deadline, sig.v, sig.r, sig.s);

Using Permit on-chain

// Call permit then transferFrom in one transaction
function depositWithPermit(
    address token,
    uint256 amount,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s);
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    // ... deposit logic
}

Common pitfall: Not all ERC-20 tokens support permit. Check for ERC-2612 support via ERC-165 or try/catch. DAI uses a non-standard permit signature (different struct).

Choosing the Right Standard

Use caseRecommended standard
Standard fungible tokenERC-20
Yield-bearing vault (staking, lending)ERC-4626 + ERC-20
Gasless UX (no approve tx)ERC-20 + ERC-2612
Operator-managed tokensERC-20 + custom operator logic (not ERC-777)
Stablecoin with off-chain approvalsERC-20 + ERC-2612 (like USDC)

Common Mistakes

  1. Not handling fee-on-transfer tokens: transferFrom may move less than amount. Track balance before/after.
  2. Assuming 18 decimals: Always call decimals(). USDC = 6, WBTC = 8.
  3. Missing return value check: Some tokens return nothing on transfer. Use SafeERC20.
  4. ERC-4626 slippage: Always use previewDeposit before depositing and set minimum shares.
  5. Permit replay: Nonces prevent replay within same chain, but cross-chain replays are possible if DOMAIN_SEPARATOR doesn't include chainId properly.