ERC-20: The Standard That Powers Most Crypto Tokens
ERC-20 is a technical standard that defines how tokens behave on Ethereum. It specifies a common interface so that any wallet, exchange, or smart contract can interact with any ERC-20 token without custom integration code.
This post covers what ERC-20 requires, how each function and event works, the approve-and-transfer pattern used in DeFi, and the known weaknesses of the standard.
What ERC-20 Actually Is
ERC stands for "Ethereum Request for Comments". ERC-20 was proposed in November 2015 by Fabian Vogelsteller. Before this standard existed, every token on Ethereum had different function names and different behavior. Wallets and exchanges had to write custom code for each token they wanted to support. That was not scalable.
ERC-20 solved this by defining a minimal, shared interface. Any contract that implements the six required functions and two required events is an ERC-20 token. That is the entire requirement. The standard says nothing about what the token represents, who can hold it, or how it is created.
Today, the most widely used tokens on Ethereum follow this standard: USDT, USDC, LINK, UNI, and thousands of others.
The Six Required Functions
totalSupply()
Returns the total number of tokens that exist.
function totalSupply() public view returns (uint256)
If a token launched with 1 million tokens, this returns 1000000000000000000000000 (accounting for 18 decimals). More on decimals below.
balanceOf(address)
Returns the number of tokens held by a specific address.
function balanceOf(address account) public view returns (uint256)
This is how wallets display your token balance. Every balance query goes through this function.
transfer(address, uint256)
Sends tokens from the caller's address to another address.
function transfer(address recipient, uint256 amount) public returns (bool)
The function must return true on success. It must revert (or return false) if the caller does not have enough tokens. It must also emit a Transfer event.
approve(address, uint256)
Authorizes another address to spend tokens on your behalf, up to a specified amount.
function approve(address spender, uint256 amount) public returns (bool)
This is the first step in the delegated transfer pattern used across DeFi. You are not moving tokens here. You are setting a permission. The actual transfer happens later.
allowance(address, address)
Returns how many tokens a spender is still allowed to move on behalf of an owner.
function allowance(address owner, address spender) public view returns (uint256)
After you approve a spender for 100 tokens and they transfer 40, allowance(yourAddress, spenderAddress) returns 60.
transferFrom(address, address, uint256)
Transfers tokens from one address to another, using an existing allowance.
function transferFrom(address sender, address recipient, uint256 amount) public returns (bool)
The caller of this function must have an allowance from sender that covers amount. This is what a DEX or lending protocol calls after you have approved it.
The Two Required Events
Events let off-chain applications track what happens on-chain without polling the blockchain constantly.
Transfer
Emitted whenever tokens move between addresses.
event Transfer(address indexed from, address indexed to, uint256 value)
When new tokens are minted, from is the zero address. When tokens are burned, to is the zero address. This convention lets indexers detect minting and burning without additional logic.
Approval
Emitted whenever approve() is called.
event Approval(address indexed owner, address indexed spender, uint256 value)A Minimal Working Implementation
Here is a complete ERC-20 implementation with no external dependencies:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleToken {
string public name = "Simple Token";
string public symbol = "SMP";
uint8 public decimals = 18;
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(uint256 initialSupply) {
_totalSupply = initialSupply;
_balances[msg.sender] = initialSupply;
// Mint event: from is zero address
emit Transfer(address(0), msg.sender, initialSupply);
}
function totalSupply() public view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address recipient, uint256 amount) public returns (bool) {
require(_balances[msg.sender] >= amount, "Insufficient balance");
_balances[msg.sender] -= amount;
_balances[recipient] += amount;
emit Transfer(msg.sender, recipient, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function transferFrom(
address sender,
address recipient,
uint256 amount
) public returns (bool) {
require(_balances[sender] >= amount, "Insufficient balance");
require(_allowances[sender][msg.sender] >= amount, "Allowance exceeded");
_balances[sender] -= amount;
_balances[recipient] += amount;
_allowances[sender][msg.sender] -= amount;
emit Transfer(sender, recipient, amount);
return true;
}
}
This contract is functional but not production-ready. For real deployments, use a battle-tested implementation like OpenZeppelin's ERC20.sol, which handles edge cases this example skips.
Optional Metadata Functions
The standard does not require these, but nearly every token includes them:
function name() public view returns (string memory)
function symbol() public view returns (string memory)
function decimals() public view returns (uint8)
The decimals value is important. Most tokens use 18, matching Ether. This means that 1 token in human terms is stored as 1000000000000000000 in the contract. When you send 1.5 SMP, you are actually passing 1500000000000000000 to transfer(). Your wallet handles this conversion so you do not have to.
Some tokens use different values. USDC uses 6 decimals. This is a design choice, not a mistake.
The Approve-TransferFrom Pattern
This two-step flow is used in every major DeFi protocol.
Step 1: You call approve(dexAddress, amount) on the token contract. This sets an allowance.
Step 2: The DEX calls transferFrom(yourAddress, poolAddress, amount) when you execute a trade.
Why two steps? Because the token contract must be the one to move funds. The DEX cannot take your tokens without your prior authorization. The approve step is that authorization.
In practice, many wallets prompt you to approve an unlimited amount (type(uint256).max) so you only pay the gas cost once. This is a UX tradeoff with real security implications: if the spender contract is ever exploited, all your approved tokens are at risk.
Minting and Burning
These operations are not in the ERC-20 standard, but most real tokens need them.
function mint(address account, uint256 amount) public onlyOwner {
_totalSupply += amount;
_balances[account] += amount;
// from = zero address signals a mint
emit Transfer(address(0), account, amount);
}
function burn(uint256 amount) public {
require(_balances[msg.sender] >= amount, "Insufficient balance");
_balances[msg.sender] -= amount;
_totalSupply -= amount;
// to = zero address signals a burn
emit Transfer(msg.sender, address(0), amount);
}
The convention of emitting Transfer with the zero address is not in the spec, but it is universally followed. Indexers and block explorers rely on it to correctly display total supply changes.
Known Weaknesses
The Approval Race Condition
Suppose you have approved a spender for 100 tokens. You then call approve() again to change it to 50. There is a window between your first transaction and the second where a watching spender can front-run you: spend the original 100 before the new approval lands, then spend 50 more after. Total damage: 150 tokens.
The safe mitigation is to set the allowance to 0 first, then set it to the new value in a second transaction.
approve(spenderAddress, 0); // First transaction
approve(spenderAddress, 50); // Second transaction
Some protocols add increaseAllowance and decreaseAllowance helpers to make this cleaner, but these are also not part of the standard.
Lost Tokens in Contracts
If you send ERC-20 tokens to a contract address that has no code to handle them, those tokens are stuck. The contract does not get notified that it received tokens. It cannot reject them. They are permanently inaccessible.
This is a real problem. Millions of dollars in tokens have been lost this way. ERC-223 and ERC-777 both tried to solve it by adding a callback mechanism, but neither gained the same adoption as ERC-20.
Always verify you are sending tokens to an address that can handle them. Sending ERC-20 tokens to a contract that has no withdrawal function results in permanent loss.
No Transfer Notification
Related to the above: contracts do not know when they receive ERC-20 tokens. If your contract needs to react to incoming tokens (trigger a swap, update accounting), you cannot rely on a callback. You have to build a separate deposit function that calls transferFrom, or instruct users to call a function after transferring.
When ERC-20 Is the Right Choice
ERC-20 is the right choice when you need a fungible token that works everywhere Ethereum infrastructure exists: wallets, DEXs, bridges, lending protocols, and block explorers.
It is not the right choice when you need:
- Non-fungible tokens (use ERC-721 or ERC-1155)
- Tokens with transfer hooks or callbacks (ERC-777 exists but has known reentrancy risks)
- Gas-efficient multi-token contracts (ERC-1155 is better here)
The standard's simplicity is also its main limitation. It does exactly what it specifies, nothing more. Any behavior beyond the six functions and two events is your responsibility to implement correctly.