ERC Token Standards Guide (ERC-20, ERC-777, ERC-4626, ERC-2612)
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
| Feature | ERC-20 | ERC-777 | ERC-4626 | ERC-2612 |
|---|---|---|---|---|
| Core purpose | Fungible token | Advanced fungible token | Yield-bearing vault token | Gasless approval extension |
| Status | Final (canonical) | Final (deprecated in practice) | Final | Final |
| Reentrancy risk | Low | High (hooks) | Medium | Low |
| Gas efficiency | High | Low | Medium | High |
| Ecosystem support | Universal | Limited | Growing (DeFi vaults) | Widely adopted |
| Extends | — | ERC-20 compatible | ERC-20 | ERC-20 |
| Key feature | Standard transfers | Send hooks | Share/asset accounting | off-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:
transferandtransferFromMUST returnbool— some tokens (USDT) don't, causing integration failures- Always use SafeERC20 (OpenZeppelin) when calling external tokens to handle non-standard returns
- The
approverace 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:
- Virtual shares: initialize with
totalSupply = 1e3,totalAssets = 1e3(OpenZeppelin's approach) - Dead shares: burn initial shares to address(0)
- 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 sharesmint(shares)→ you specify how many shares you want, pay the required assetswithdraw(assets)→ you specify how many assets you want back, burn calculated sharesredeem(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 case | Recommended standard |
|---|---|
| Standard fungible token | ERC-20 |
| Yield-bearing vault (staking, lending) | ERC-4626 + ERC-20 |
| Gasless UX (no approve tx) | ERC-20 + ERC-2612 |
| Operator-managed tokens | ERC-20 + custom operator logic (not ERC-777) |
| Stablecoin with off-chain approvals | ERC-20 + ERC-2612 (like USDC) |
Common Mistakes
- Not handling fee-on-transfer tokens:
transferFrommay move less thanamount. Track balance before/after. - Assuming 18 decimals: Always call
decimals(). USDC = 6, WBTC = 8. - Missing return value check: Some tokens return nothing on
transfer. Use SafeERC20. - ERC-4626 slippage: Always use
previewDepositbefore depositing and set minimum shares. - Permit replay: Nonces prevent replay within same chain, but cross-chain replays are possible if DOMAIN_SEPARATOR doesn't include chainId properly.