ERC Token Standards
Overview
Ethereum Request for Comments (ERC) standards define the public interface contracts must implement to be recognised as a particular kind of token. The interfaces are deliberately minimal — a function signature, an event, a transfer semantic — so that any compliant contract can be dropped into a wallet, marketplace, DEX, or governance system without bespoke integration. This page covers the four primary token standards (ERC-20, ERC-721, ERC-1155, ERC-4626), the permit extension (EIP-2612), and the practical differences that matter when picking one.
ERC-20: Fungible Tokens
EIP-20, finalised in 2017, defines the fungible token interface. Every ERC-20 token has the same nine methods and two events:
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function name() external view returns (string memory); // optional
function symbol() external view returns (string memory); // optional
function decimals() external view returns (uint8); // optional
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
Key semantics:
transfer(to, amount): moves balance frommsg.sendertoto. Must emitTransfer. Reverts on insufficient balance or (post-2019) whentois the zero address.approve(spender, amount): grantsspenderan allowance ofamountfrommsg.sender. Must emitApproval.transferFrom(from, to, amount): spender calls this; the contract checksallowance[from][spender] >= amount, decrements the allowance, moves the tokens.
Two long-standing footguns
-
Approval front-running (the “infinite approval” problem): if you approve Alice for 100, then later approve her for 50, the second tx is in the mempool. Alice sees it, front-runs with
transferFromfor 100, and the second approval then sets her to 50 — she’s withdrawn 150 total. The fix in practice is thepermitextension (see below) orincreaseAllowance/decreaseAllowanceinstead of absoluteapprove. Many production contracts (Compound’s cToken, Uniswap’s LP token) make this worse by allowingtype(uint256).maxas a “no-allowance-check” sentinel. -
transferto a contract that doesn’t support ERC-20: the recipient contract has noonERC20Receivedcallback (ERC-20 has none, unlike ERC-721’sonERC721Received). Tokens sent to a contract with notransferhandler are locked permanently — the 2017 Parity wallet incident locked 513k ETH; similar accidents on token contracts happen weekly. Mitigation: useSafeERC20.transferwhich checks the return data for the bool and reverts if missing.
The total supply of USDC, USDT, DAI — the three largest stablecoins — are all ERC-20. The standard survives because the interface is minimal and contract composability wins over feature richness.
EIP-2612: Permit Extension
EIP-2612 adds a permit method to ERC-20. Instead of approve (which requires an on-chain transaction from the user, costing ~50k gas), the user signs an EIP-712 typed-data message off-chain. The spender submits the signature + the message in a single permit call, which writes the allowance on the user’s behalf:
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
The signed payload is keccak256("\x19\x01" ++ domainSeparator ++ hashStruct(Permit)) where Permit is permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline). The contract tracks nonces[owner] to prevent replay.
Gas savings: the user pays zero gas for the approval (signature is off-chain). The spender folds the permit and the transferFrom into one transaction. The pattern is universal for ERC-20 vaults, DAI (PERMIT-type-1), and most L2 native tokens.
A related extension, EIP-3009 (TransferWithAuthorization), lets the spender pull tokens with a signed message without writing to the allowance mapping — saving one SSTORE. Used by USDC since v2.
ERC-721: Non-Fungible Tokens
EIP-721 introduces NFTs. Each token is unique and identified by a uint256 tokenId:
interface IERC721 {
function balanceOf(address owner) external view returns (uint256);
function ownerOf(uint256 tokenId) external view returns (address);
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
function transferFrom(address from, address to, uint256 tokenId) external;
function approve(address to, uint256 tokenId) external;
function getApproved(uint256 tokenId) external view returns (address);
function setApprovalForAll(address operator, bool approved) external;
function isApprovedForAll(address owner, address operator) external view returns (bool);
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
}
Notable differences vs. ERC-20:
ownerOf: lookups are bytokenId, not by(owner, token)pair. The standard does not specify how owner is stored — most implementations usemapping(uint256 => address) _owners.safeTransferFrom: after the transfer, callsIERC721Receiver(to).onERC721Received(...)and reverts if the recipient returns the wrong magic value. This prevents accidentally sending to contracts that don’t understand NFTs.setApprovalForAll: per-operator, per-collection allowance. The standard does not define per-token approval beyond the legacyapprove(tokenId)which is for a single token at a time.supportsInterface(ERC-165): the contract returnstruefor0x80ac58cd(the ERC-721 interface id) so consumers can detect support without trying a call.
Metadata extension
EIP-721 also defines IERC721Metadata: name(), symbol(), tokenURI(uint256 tokenId) returns (string). The URI convention is JSON with name, description, image, attributes. Marketplaces (OpenSea, Blur, Magic Eden) fetch this off-chain — the contract itself stores nothing but the URI template (e.g. https://api.example.com/metadata/{id}.json). The art/data is off-chain; the contract only commits to where to find it.
This is the weakest link in NFT design: the contract guarantees token ownership but not metadata. If the off-chain server returns different JSON tomorrow, the “asset” changes. Immutable’s fix is to store the metadata IPFS hash on-chain; the more general fix is to render fully on-chain (Autoglyphs, ChainRunners, Nouns).
Enumeration
ERC-721’s optional IERC721Enumerable exposes totalSupply() and tokenOfOwnerByIndex(owner, index). OpenSea uses this to list a wallet’s collection. The naive implementation (uint256[] _allTokens plus mapping(uint256 => uint256) _allTokensIndex) costs O(1) at the price of SSTORE on every mint and burn. Most contracts skip this and rely on The Graph for enumeration — gas-protocol trade-off.
ERC-1155: Multi-Token
EIP-1155 is a multi-token standard: a single contract manages many token types, both fungible and non-fungible. Each token type has a uint256 tokenId, and balances are stored in mapping(address => mapping(uint256 => uint256)). This collapses a game’s item catalogue (1000 swords, 500 shields, 1 of each legendary) into one contract rather than 1500 separate ERC-20 and ERC-721 deployments.
interface IERC1155 {
function balanceOf(address account, uint256 id) external view returns (uint256);
function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
external view returns (uint256[] memory);
function safeBatchTransferFrom(address from, address to, uint256[] calldata ids,
uint256[] calldata amounts, bytes calldata data) external;
event TransferSingle(address indexed operator, address indexed from,
address indexed to, uint256 id, uint256 value);
event TransferBatch(address indexed operator, address indexed from,
address indexed to, uint256[] ids, uint256[] values);
// ... mint / burn events
}
Differences vs. ERC-721:
- Batch transfers:
safeBatchTransferFromtakes arrays — one transaction can move a basket of items. Saves gas because the calldata overhead is amortised and the loop touches the same storage slot. - No separate approval per token: only
setApprovalForAll(operator, approved)— a single bool per operator. The standard does not haveapprove(tokenId)because per-token approval is meaningless when each token type may have a billion identical copies. - URI templates:
uri(uint256 id) returns (string)returns a template — typicallyhttps://api.example.com/item/{id}.jsonand clients substitute the hex-encodedid. - Fungible + non-fungible in one: a token with supply 1 is effectively an NFT; a token with supply 10⁹ is effectively a fungible token. The same contract can hold both.
ERC-1155 dominates game item contracts (Enjin, Gods Unchived, Immutable) and was used by OpenSea’s shared storefront (the so-called “shared” 1155 contract that hosts ~95% of OpenSea’s secondary listings).
ERC-4626: Tokenized Vault
EIP-4626, finalised in 2022, standardises yield-bearing vaults. The vault holds an underlying ERC-20 (the “asset”); users deposit assets and receive ERC-20 shares whose value grows with the vault’s yield. The standard wraps both the deposit / withdraw flow and the accounting, so any vault can plug into any front-end or aggregator.
interface IERC4626 is IERC20 {
function asset() external view returns (address);
function totalAssets() external view returns (uint256);
function convertToShares(uint256 assets) external view returns (uint256);
function convertToAssets(uint256 shares) external view returns (uint256);
function deposit(uint256 assets, address receiver) external returns (uint256);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256);
function redeem(uint256 shares, address receiver, address owner) external returns (uint256);
function maxDeposit(address receiver) external view returns (uint256);
function previewDeposit(uint256 assets) external view returns (uint256);
// ... + withdraw / redeem / mint / withdraw variants
event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares);
event Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares);
}
The standard is a strong extension of ERC-20: shares are themselves an ERC-20, so a vault can be deposited into another vault (Yearn did this with yvTokens inside yvTokens), the share token can be AMM’d, etc. The four core operations (deposit, withdraw, mint, redeem) come with preview/max companions so integrators can quote without reverting.
The first-depositor attack
Without protection, an attacker can manipulate totalAssets to make share prices unfavourable for later depositors:
- Vault is empty. Attacker deposits 1 wei of asset, gets 1 share.
- Attacker donates
2^128 - 1of asset directly viaasset.transfer(vault, huge).totalAssets()now reads as huge. - Next depositor deposits 100k assets. The vault computes
shares = 100k * 1 / (huge)≈ 0 — depositor gets 0 shares for 100k of value, attacker steals it on withdraw.
OpenZeppelin’s ERC4626 implementation introduces virtual assets and shares: the vault pretends it has 10 ** decimals_offset virtual assets and shares at deployment, so the initial share price is well-defined and the donation attack requires an economically-unreasonable donation. The offset scales with the underlying token’s decimals — for a USDC (6-decimals) vault, the offset is 1e12; for a WETH (18-decimals) vault, the offset is 0.
Inflation and deflation attacks
A related concern is the deflationary token underlying — if asset.transferFrom charges a 1% fee on transfer, the vault gets less than it accounts for. The standard does not require this; production vaults that wrap fee-on-transfer tokens typically override _deposit and _withdraw to use actual balances rather than the requested amounts. The Yearn V3 vault and the Morpho vaults handle this explicitly.
Comparison
+---------------+----------+----------+----------+----------+
| Standard | ERC-20 | ERC-721 | ERC-1155 | ERC-4626 |
+---------------+----------+----------+----------+----------+
| Token model | fungible | unique | both | shares |
| Identifiers | account | tokenId | (acct,id)| share |
| Storage | (a)->u | id->a | (a,id)->u| (a)->u |
| Transfer | simple | single | batch | mint/burn|
| Receiver cb | none | yes | yes | n/a |
| Approval | allowance| per-id | per-acct | ERC-20 |
| Standard size | 9 fn | 11 fn | 6 fn | 21 fn |
| Use case | money | art | games | vaults |
| Examples | USDC | Crypto- | Gods | Yearn v3 |
| | DAI | Punks | Unchained| Morpho |
+---------------+----------+----------+----------+----------+
When to pick which:
- ERC-20: money, governance tokens, voting shares. If every unit is interchangeable, ERC-20.
- ERC-721: one-of-one collectibles, ENS domains, real-estate titles, identity tokens. The “this is that token” property matters.
- ERC-1155: games with many item types; collections where shared metadata is more efficient than per-token contracts; semi-fungible items (e.g. season-pass tiers). Batch transfers are a real cost win.
- ERC-4626: any vault that holds an asset and issues shares. If you find yourself writing
deposit(),withdraw(),totalAssets(), you should implement ERC-4626 instead — it gets you free composability with Yearn, Morpho, Zerolend, and every aggregator.
Common Gotchas
- Reentrancy on
transfer: ERC-20’stransferto a contract can reenter via its fallback. ERC-721’ssafeTransferFromexplicitly invokes the receiver hook. ApplynonReentrantto all mutating functions that touch shares or balances — not justwithdraw. - Solidity
transfervs. tokentransfer:address(this).transfer(x)is the 2300-gas EVM primitive;IERC20(token).transfer(to, x)is a high-level call that forwards gas. The former is being deprecated; the latter is what tokens mean. decimals()is optional: don’t assume18(USDC is 6). Hardcoding decimals breaks integrations. Always read it dynamically.- Token pauses: USDT has a
pauseflag — when paused,transferreverts silently with no return value.SafeERC20handles this by reverting on a missing bool return. - Rebasing tokens:
balanceOfof a rebasing token (stETH, aToken) changes over time. The vault’s accounting breaks if it assumes balances are constant between deposit and withdraw. - Frontrunning the mint: NFT mints can be sandwiched by reading the public mempool and outbidding gas. Mitigation: Merkle-tree allowlists and off-chain signatures.
Interview Questions
Q1: Why does ERC-20 lack a “transfer-and-call” function while ERC-721 and ERC-1155 have one?
ERC-20 was finalised in 2017, predating the composability lessons of the next few years. By the time ERC-721 and ERC-1155 were designed, the pattern of “if you send tokens to a contract, call a hook so the recipient knows” was well established. EIP-1363 retrofits this onto ERC-20 (transferAndCall), but adoption is patchy. The lesson: a missing callback is a security vulnerability in practice (locked tokens), so newer standards learn from older mistakes.
Q2: What’s the difference between ERC-721 and ERC-1155 for a game with 1000 unique items and 1M fungible gold tokens?
ERC-721 would require 1000 contract deployments (or one contract with 1000 unique tokenIds, but then the gold token can’t be modelled as ERC-721). ERC-1155 handles both in one contract: the 1000 items are tokenIds with supply 1, the gold is one tokenId with supply 1M. Transfers are batched, gas is amortised, the contract surface is half the size. For games, ERC-1155 is almost always right; for art collections (Bored Apes, CryptoPunks) ERC-721 is the convention.
Q3: How does permit save gas, and what does it cost in trust model?
Without permit, the user submits an approve transaction (~50k gas: one SSTORE plus 21k base). With permit, the user signs off-chain (free), the spender folds the permit call into the transaction that does the work — one extra SLOAD + SSTORE plus ecrecover (3k gas). Trust model: the signature is a one-time credential; the user signs exactly what they authorised, so there’s no extra trust.
Q4: Explain the first-depositor attack on an ERC-4626 vault.
The vault is empty. Attacker mints 1 share for 1 asset, then donates a huge amount of asset directly via asset.transfer(vault, huge). totalAssets() reports huge + 1. The next user deposits 100k assets; the vault computes shares_mint = 100k * 1 / (huge + 1) ≈ 0. The depositor gets 0 shares for 100k of assets; the attacker then redeems their single share for the entire vault balance. OpenZeppelin’s ERC4626 adds a virtual offset so the donation must exceed that virtual baseline — economically unfeasible.
References
- EIP-20: ERC-20 token standard
- EIP-721: ERC-721 non-fungible token standard
- EIP-1155: multi-token standard
- EIP-4626: tokenized vault standard
- EIP-2612: permit extension for ERC-20
- EIP-165: standard interface detection
- EIP-1363: payable token with callbacks
- EIP-3009: transfer with authorisation (ERC-20 extension)
- OpenZeppelin ERC-4626 implementation
Related Topics
- Solidity — Contract structure, modifiers, ABI encoding
- EVM Internals — Storage layout, gas, opcodes used in token transfers
- Smart Contract Security — Reentrancy, overflow, access control on token contracts
- Ethereum Internals — Gas model that drives ERC costs, EIP-1559