What a fungible token really is
Picture the chip cage at a casino. You walk up, hand over cash, and get back a stack of identical $5 chips. Every $5 chip is worth exactly the same as every other — nobody cares which one you hold, and you can hand any of them to anyone at the table. That interchangeability is what fungible means, and a fungible token is the on-chain version of that chip: a unit of value where every token is worth precisely as much as every other.
Strip away the mystique and a token contract is astonishingly simple — it is a spreadsheet. One column holds addresses, the other holds how many tokens each address owns. Sending a token is not moving a coin anywhere; it is the contract editing two rows: subtract from you, add to the recipient. There is no file, no object, no actual coin — just a smart contract that keeps a ledger of balances and lets you rearrange it under rules it enforces.
So why a standard? Because a token is only useful if other software can read and move it. If every project invented its own function names — sendCoins here, pay there — every wallet, exchange, and DeFi app would need custom code for each one. ERC-20 is the agreement that ends that chaos: a small, fixed set of function names and behaviors that every fungible token on Ethereum promises to implement. Implement them, and the entire ecosystem understands your token for free. ERC-20 is less a piece of code than a token standard — a social contract written in function signatures.
The interface: six functions and two events
Because ERC-20 is an agreement rather than an implementation, the cleanest way to see it is as a Solidity interface — a list of function signatures with no bodies. Any contract that implements all of these is an ERC-20 token, no matter how the internals work:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}Read it top to bottom and the design tells a story. The three view functions answer questions: totalSupply (how many tokens exist), balanceOf (how many does this address hold), and allowance (how much may a spender move on someone's behalf). transfer moves your own tokens. The pair approve + transferFrom is the clever part — it lets a third party, such as an exchange contract, move your tokens with your permission. We will reach that pair after the simple case.
Then there are the two events. Transfer is emitted on every movement of tokens, and Approval on every change of permission. These are not decorative — they are the standard's promise to the outside world. Block explorers, wallets, and accounting tools do not re-run your contract; they watch the log stream and reconstruct balances from Transfer events. Forget to emit one and your token will appear to move money invisibly, breaking every tool that watches it.
Building the core: balances and transfer
Now we implement, starting with the part that needs no permissions: holding balances and moving your own tokens. The whole ledger is one mapping from address to balance. Declaring it public is a small trick — Solidity auto-generates a getter function with exactly the balanceOf(address) signature the standard demands, so the state variable is the view function. The same trick gives us totalSupply and allowance for free.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MiniToken {
string public name = "Mini Token";
string public symbol = "MINI";
uint8 public constant decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
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;
balanceOf[msg.sender] = initialSupply;
emit Transfer(address(0), msg.sender, initialSupply);
}
function transfer(address to, uint256 value) public returns (bool) {
require(to != address(0), "ERC20: transfer to the zero address");
balanceOf[msg.sender] -= value; // reverts on underflow in 0.8+
balanceOf[to] += value;
emit Transfer(msg.sender, to, value);
return true;
}
}Walk transfer. It checks the recipient is not the zero address (a common way tokens get burned by accident), subtracts value from the caller, adds it to the recipient, and emits Transfer. Notice what is missing: there is no explicit require that the sender has enough balance. We do not need one. Since Solidity 0.8, arithmetic is checked by default — if the subtraction would drop below zero, the underflow is caught and the whole transaction reverts automatically.
The constructor performs an initial mint. There is no special mint opcode; minting just means increasing totalSupply and crediting an account. By convention we emit a Transfer event with the sender field set to address(0) — the zero address stands in for "out of thin air," and indexers rely on this convention to count a token's issuance. Burning is the mirror image: send to address(0) and decrease supply.
approve and transferFrom: lending out spending power
Direct transfer works when you are the one moving tokens. But the whole point of DeFi is that contracts move tokens for you — a decentralized exchange has to pull your tokens out of your wallet to complete a swap. A contract cannot reach into your account and take them (thankfully). So ERC-20 borrows an idea from the offline world: a signed permission slip. You tell the token contract, in advance, "this exchange may spend up to 500 of my tokens." That stored permission is an allowance.
The pattern has two moves. First you call approve(spender, amount), which records that spender may move up to amount of your tokens — this is the token approval step, and it emits Approval. Later, the spender (or whatever contract the protocol routes through) calls transferFrom(you, recipient, amount), which moves your tokens and deducts from the allowance. Here are the three functions; add them to the contract above:
function approve(address spender, uint256 value) public returns (bool) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value)
public
returns (bool)
{
require(to != address(0), "ERC20: transfer to the zero address");
allowance[from][msg.sender] -= value; // reverts if allowance too low
balanceOf[from] -= value; // reverts if balance too low
balanceOf[to] += value;
emit Transfer(from, to, value);
return true;
}Trace the two-party flow. Say you want to swap on an exchange contract DEX. (1) You call approve(DEX, 500) on the token — now the allowance from you to DEX is 500. (2) You tell DEX to swap; inside that call, DEX calls transferFrom(you, DEX, 500). The token checks the allowance, decrements it to 0, moves 500 from you to DEX, and emits Transfer. Two checked-arithmetic subtractions guard the whole thing: too little allowance or too little balance and it reverts. Note that transferFrom is called by the spender, so msg.sender is DEX, and the allowance consulted is the one keyed by the owner and the caller — exactly the slot approve wrote.
Where it bites: the approval race and lying tokens
The approve function has a famous wrinkle. It overwrites the allowance rather than adjusting it, and that creates a front-running window known as the approval race condition. Suppose spender already holds an allowance of 100 from you and you decide to lower it to 50. You send approve(spender, 50). A malicious spender watching the mempool can rush a transferFrom for the old 100 before your approve lands, then spend another 50 after it lands — extracting 150 against the 50 you intended.
The textbook mitigation is to never change a non-zero allowance in one step: first approve(spender, 0), confirm it, then approve(spender, newAmount). Many implementations also added non-standard increaseAllowance / decreaseAllowance helpers that adjust by a delta instead of overwriting, sidestepping the race. The modern, cleaner answer is EIP-2612 permit, which replaces the separate approve transaction with a single signed message — fewer transactions, a smaller window. In practice this race is somewhat theoretical, and the larger real-world danger is the opposite habit: approving an unlimited amount for convenience and then forgetting about it.
There is a second, nastier trap, and it lives in that innocent returns(bool). The standard says transfer and approve should return a boolean and callers should check it. But history is messy. Some hugely popular tokens — Tether's USDT is the classic — were written before the boolean convention settled and return nothing at all. Others return false on failure instead of reverting, so code that ignores the return value happily continues as if the transfer had succeeded. Build a protocol that assumes every token behaves, and a single misbehaving token can quietly corrupt your accounting.
This is why OpenZeppelin ships a helper library called SafeERC20. Its safeTransfer, safeTransferFrom, and forceApprove wrap the raw call: they handle tokens that return nothing, and they revert when a token returns false, giving every token a single, sane behavior. The rule of thumb for production code that touches arbitrary tokens: never call transfer or transferFrom directly — route them through SafeERC20.
Standing on giants: OpenZeppelin and the metadata
Before we hand the keys to a library, one honest detail about those name, symbol, and decimals fields at the top of our contract: they are optional metadata, not part of the required six functions. decimals is purely cosmetic. The contract only ever stores whole integers; decimals = 18 simply tells wallets to display the stored number with the decimal point shifted 18 places. So "1 token" with 18 decimals is stored as 1000000000000000000. Eighteen is the near-universal convention (it mirrors how 1 ether equals 10^18 wei), but a few well-known tokens differ — USDC and USDT use 6 — and code that hard-codes 18 will misprice them.
We built every line ourselves to understand it, but you should almost never ship a hand-rolled ERC-20. The reference implementation from OpenZeppelin has been audited, battle-tested across trillions of dollars of value, and handles the edge cases — zero-address checks, event correctness, hooks for extensions — that are easy to get subtly wrong. In real code you inherit it. Using Solidity inheritance, an entire, correct, production-grade token is just a few lines:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor() ERC20("My Token", "MTK") {
// mint one million tokens to the deployer
_mint(msg.sender, 1_000_000 * 10 ** decimals());
}
}That is the whole thing. ERC20 provides transfer, approve, transferFrom, the balances and allowance mappings, the events, and a default decimals() of 18. You supply only what is unique to your token: its name, symbol, and how the initial supply is minted. The internal _mint does exactly what our constructor did by hand — increase totalSupply, credit an account, emit Transfer from the zero address. From here you compose: add ERC20Burnable for burning, ERC20Permit for gasless approvals, Ownable plus a mint function for a controlled supply.