ERC-20 is the most widely deployed smart contract standard in existence. At last count, over 500,000 ERC-20 tokens exist on Ethereum mainnet alone. Yet most developers who use ERC-20 tokens have never read the standard carefully enough to understand its subtleties. This post implements ERC-20 from scratch (without OpenZeppelin) and audits it for common vulnerabilities.
Table of contents
Open Table of contents
The ERC-20 Interface
The complete interface is 6 functions and 2 events. Everything else is implementation detail:
// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
interface IERC20 { // Events event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value);
// View functions function totalSupply() external view returns (uint256); function balanceOf(address account) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256);
// State-changing functions function transfer(address to, uint256 value) external returns (bool); function approve(address spender, uint256 value) external returns (bool); function transferFrom(address from, address to, uint256 value) external returns (bool);}That’s it. The entire standard. Let’s implement it.
Full Implementation
// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
import "./IERC20.sol";
contract MyToken is IERC20 { string public name; string public symbol; uint8 public decimals; uint256 private _totalSupply;
mapping(address => uint256) private _balances; mapping(address => mapping(address => uint256)) private _allowances;
constructor( string memory _name, string memory _symbol, uint8 _decimals, uint256 initialSupply ) { name = _name; symbol = _symbol; decimals = _decimals;
// Mint initial supply to deployer _totalSupply = initialSupply * 10 ** _decimals; _balances[msg.sender] = _totalSupply; emit Transfer(address(0), msg.sender, _totalSupply); }
function totalSupply() external view returns (uint256) { return _totalSupply; }
function balanceOf(address account) external view returns (uint256) { return _balances[account]; }
function allowance(address owner, address spender) external view returns (uint256) { return _allowances[owner][spender]; }
function transfer(address to, uint256 value) external returns (bool) { _transfer(msg.sender, to, value); return true; }
function approve(address spender, uint256 value) external returns (bool) { _approve(msg.sender, spender, value); return true; }
function transferFrom( address from, address to, uint256 value ) external returns (bool) { uint256 currentAllowance = _allowances[from][msg.sender]; require(currentAllowance >= value, "ERC20: insufficient allowance");
// Decrement allowance (unchecked saves gas, we just verified) unchecked { _approve(from, msg.sender, currentAllowance - value); }
_transfer(from, to, value); return true; }
// Internal functions
function _transfer(address from, address to, uint256 value) internal { require(from != address(0), "ERC20: transfer from zero address"); require(to != address(0), "ERC20: transfer to zero address"); require(_balances[from] >= value, "ERC20: insufficient balance");
unchecked { _balances[from] -= value; _balances[to] += value; }
emit Transfer(from, to, value); }
function _approve(address owner, address spender, uint256 value) internal { require(owner != address(0), "ERC20: approve from zero address"); require(spender != address(0), "ERC20: approve to zero address");
_allowances[owner][spender] = value; emit Approval(owner, spender, value); }
function _mint(address to, uint256 value) internal { require(to != address(0), "ERC20: mint to zero address"); _totalSupply += value; _balances[to] += value; emit Transfer(address(0), to, value); }
function _burn(address from, uint256 value) internal { require(from != address(0), "ERC20: burn from zero address"); require(_balances[from] >= value, "ERC20: burn exceeds balance"); unchecked { _balances[from] -= value; _totalSupply -= value; } emit Transfer(from, address(0), value); }}Deep Dive: Every Design Decision
decimals: uint8
ERC-20 doesn’t mandate a specific decimal count, but 18 is the standard (matching ETH’s wei denomination). USDC uses 6 decimals, WBTC uses 8. When you see “1 USDT”, the contract stores 1,000,000 (10^6). Always work in base units to avoid rounding errors.
// BAD: This sends 1 token with 18 decimals// (1 "atom" — equivalent to 0.000000000000000001 tokens)token.transfer(recipient, 1);
// GOOD: This sends 1 full tokentoken.transfer(recipient, 1 * 10**18);// or equivalently:token.transfer(recipient, 1 ether); // 'ether' is just an alias for 10^18The Double Mapping: _allowances
mapping(address => mapping(address => uint256)) private _allowances;This is the “approvals” ledger. _allowances[alice][bob] = 100 means Bob is allowed to spend up to 100 tokens on Alice’s behalf via transferFrom(). This enables:
- DEX swaps: You approve Uniswap’s router to spend your tokens, then call
swap() - DeFi deposits: You approve Aave to pull your USDC, then call
supply() - Subscriptions: You approve a service to pull monthly fees
unchecked Arithmetic
Solidity 0.8.x adds overflow/underflow protection by default. unchecked { } bypasses this check for gas savings when we’ve already proven safety:
require(_balances[from] >= value, "..."); // Proof: from has enough
unchecked { _balances[from] -= value; // Safe: we just verified balance >= value _balances[to] += value; // Safe: sum of all balances = totalSupply (fits uint256), so no single balance can overflow}This saves ~100-200 gas per transfer — meaningful at Ethereum’s scale.
Security Vulnerabilities to Know
1. The Approval Race Condition
The original ERC-20 approve() function has a well-known vulnerability. Suppose Alice has approved Bob for 100 tokens. She decides to change it to 50. She sends a transaction. Bob front-runs it:
- Alice’s
approve(Bob, 100)is pending in mempool - Alice sends
approve(Bob, 50)to reduce it - Bob front-runs:
transferFrom(Alice, Bob, 100)— Bob spends 100 - Alice’s new
approve(Bob, 50)goes through - Bob calls
transferFrom(Alice, Bob, 50)again — now Bob has 150 total
The EIP-20 standard authors knew about this and recommended using approve(Bob, 0) first, then approve(Bob, newAmount). Modern tokens add increaseAllowance() and decreaseAllowance() helpers, or use the permit() function (EIP-2612) which avoids on-chain approvals entirely.
2. Missing Return Value
Early ERC-20 tokens (USDT, BNB) were deployed before the standard specified return values. Some return nothing instead of true. This breaks callers that assume:
require(IERC20(token).transfer(to, amount)); // REVERTS for non-standard tokens!Always use OpenZeppelin’s SafeERC20.safeTransfer() in production, which handles non-standard implementations.
3. ERC-777 Reentrancy via Hooks
ERC-777 is a superset of ERC-20 that adds “hooks” — callbacks triggered before and after transfers. These hooks enable reentrancy attacks:
// Malicious token with hook that calls back into the victimfunction tokensToSend(address, address, address, uint256 amount, ...) external { victim.deposit(amount); // Reenters before state is updated!}This is the vector behind the April 2020 imBTC/Uniswap V1 incident ($300K drained from Uniswap) and the concurrent Lendf.Me exploit ($25M). Always follow Checks-Effects-Interactions pattern.
4. Transfer to address(0) Burns Tokens
In our implementation, we guard against zero-address transfers. But some older tokens don’t. Sending to 0x0000...0000 doesn’t revert — it just permanently burns the tokens (they go to an address nobody controls). Billions in tokens have been accidentally burned this way.
5. Integer Overflow (Pre-0.8.x)
Before Solidity 0.8, arithmetic silently wrapped around. uint256 max + 1 = 0. The SafeMath library was added to every token for exactly this reason. Solidity 0.8+ makes it built-in. If you’re auditing older contracts, check every arithmetic operation.
Gas Costs Reference
Deploying our MyToken contract costs approximately:
| Operation | Gas | Cost at 20 gwei / $3,000 ETH |
|---|---|---|
| Deploy contract | ~650,000 | ~$39 |
transfer() | ~21,000 | ~$1.26 |
approve() + transferFrom() | ~43,000 | ~$2.58 |
mint() (new account) | ~66,000 | ~$3.96 |
These are approximations — actual gas depends on SSTORE costs (new vs existing slots) and EIP-1559 base fee at execution time.
Testing With Foundry
# Install Foundrycurl -L https://foundry.paradigm.xyz | bashfoundryup
# Create projectforge init my-tokencd my-tokenpragma solidity ^0.8.20;
import "forge-std/Test.sol";import "../src/MyToken.sol";
contract MyTokenTest is Test { MyToken token; address alice = address(1); address bob = address(2);
function setUp() public { token = new MyToken("MyToken", "MTK", 18, 1_000_000); // Transfer some to alice for testing token.transfer(alice, 1000 * 1e18); }
function test_transfer() public { vm.prank(alice); token.transfer(bob, 100 * 1e18); assertEq(token.balanceOf(bob), 100 * 1e18); assertEq(token.balanceOf(alice), 900 * 1e18); }
function test_transferFrom() public { vm.prank(alice); token.approve(address(this), 500 * 1e18);
token.transferFrom(alice, bob, 200 * 1e18); assertEq(token.allowance(alice, address(this)), 300 * 1e18); assertEq(token.balanceOf(bob), 200 * 1e18); }
function testFuzz_transfer(uint256 amount) public { amount = bound(amount, 0, token.balanceOf(alice)); vm.prank(alice); token.transfer(bob, amount); assertEq(token.balanceOf(bob), amount); }}forge test -vvFoundry runs Solidity tests in Solidity (no JavaScript/TypeScript overhead), with built-in fuzzing that generates thousands of random inputs to find edge cases.
What OpenZeppelin Gets Right
OpenZeppelin’s ERC20.sol adds over our implementation:
increaseAllowance()/decreaseAllowance()— mitigates the race condition_beforeTokenTransfer()/_afterTokenTransfer()hooks — extensibility- ERC20Burnable, ERC20Capped, ERC20Snapshot extensions
- Comprehensive test coverage
For production tokens, use OpenZeppelin. Write from scratch for learning — then you’ll understand exactly what the library is doing.
The ERC-20 standard is small enough to hold in your head. Its attack surface is also smaller than most think. The real complexity is in the protocols built on top of it — where approvals, flash loans, and price oracles interact in ways the original authors didn’t anticipate.