Skip to content

ERC-20 Standard — Building and Auditing a Token from Scratch

Posted on:April 9, 2024 at 10:00 AM

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: MIT
pragma 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: MIT
pragma 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 token
token.transfer(recipient, 1 * 10**18);
// or equivalently:
token.transfer(recipient, 1 ether); // 'ether' is just an alias for 10^18

The 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:

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:

  1. Alice’s approve(Bob, 100) is pending in mempool
  2. Alice sends approve(Bob, 50) to reduce it
  3. Bob front-runs: transferFrom(Alice, Bob, 100) — Bob spends 100
  4. Alice’s new approve(Bob, 50) goes through
  5. 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 victim
function 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:

OperationGasCost 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

Terminal window
# Install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Create project
forge init my-token
cd my-token
test/MyToken.t.sol
pragma 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);
}
}
Terminal window
forge test -vv

Foundry 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:

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.