ERC-20 from scratch - writing a token in Solidity and analysing every line
A minimal ERC-20 implementation in Solidity: balances, transfers, allowances, events, common mistakes and why proven libraries are needed in production.
In this article
An ERC-20 token seems like magic until you see the source: it is a small contract with two tables and three functions. Here we will write it out in full and analyse each part. A conceptual overview is in the article tokens and NFTs, the mechanics of approve in a separate walkthrough. The syntax is in the Solidity tour.
What the standard requires
ERC-20 defines an interface: the functions totalSupply, balanceOf, transfer, approve, allowance, transferFrom and the events Transfer, Approval. Optional: name, symbol, decimals.
The full code
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract SimpleToken {
string public name = "Simple Token";
string public symbol = "SIMP";
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) {
_mint(msg.sender, initialSupply);
}
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) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
require(allowed >= value, "allowance");
allowance[from][msg.sender] = allowed - value;
}
_transfer(from, to, value);
return true;
}
function _transfer(address from, address to, uint256 value) internal {
require(to != address(0), "zero address");
require(balanceOf[from] >= value, "balance");
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value);
}
function _mint(address to, uint256 value) internal {
totalSupply += value;
balanceOf[to] += value;
emit Transfer(address(0), to, value);
}
}Part-by-part analysis
Two tables
balanceOf[address]- how many tokens an address has.allowance[owner][spender]- how much the owner has allowed to be spent.
That is all of the token's "state". Tokens do not "lie" in wallets - they are records in balanceOf (what is stored on the blockchain). The tables live in slots by key.
Decimals
decimals = 18 means that 1000000000000000000 units make one "whole" token. The contract works only with integers; the decimal point is drawn by the interface (why). USDC has decimals = 6, many tokens have 18, and this must be accounted for in calculations.
transfer
A simple transfer: check the balance, decrease the sender's, increase the recipient's, record the event. The function selector of transfer(address,uint256) is 0xa9059cbb (what a selector is).
approve and transferFrom
A two-step "allow - spend" scheme:
- The owner calls
approve(spender, value). A record appears in theallowancetable. - The contract (the spender) calls
transferFrom(owner, recipient, value). It checks the permission, reduces it and performs the transfer.
This is exactly how our escrow takes the buyer's tokens: the buyer first approves an amount, then the contract pulls it in createOffer or acceptOffer.
The line if (allowed != type(uint256).max) is a common convention: an "infinite" allowance is not reduced, which saves gas. But it is dangerous: the contract can spend all the tokens (risks).
Events
Transfer and Approval are what wallets and explorers build history from. The rule: when tokens are created, the Transfer event goes from the zero address (address(0)), when burned - to the zero address. The hash of the signature Transfer(address,address,uint256) is the event's first topic, beginning with 0xddf252ad (about the event log).
The zero-address check
require(to != address(0)) guards against sending "into nothing" by accident, where tokens are lost forever (though in practice "burned" tokens are often sent to the zero or a special address on purpose).
What is simplified here
The minimal implementation lacks:
- Protection from the approve race. If you change an allowance from 100 to 50, the spender may manage to spend 100 before the change and then another 50. The fix: zero it first or use
increaseAllowance. - Permit (EIP-2612). Permission by signature without a separate transaction (signatures).
- Issuance control. Who can create tokens and how is the most important part of the design (access control).
- Pauses and address blocking, which centralised stablecoins have.
Non-standard tokens in reality
Real tokens break the standard in different ways:
- USDT on some networks does not return a
boolfromtransfer. Contracts must useSafeERC20, which accepts both variants. - Fee-on-transfer tokens deliver less than was sent. That is why the escrow compares the amount received with the declared one and rejects such tokens.
- Rebasing tokens change balances by themselves.
- Address blocking (USDC, USDT): a transfer to a specific address may be rejected. In the escrow a payout is then deferred as a "credit".
A look at all the pitfalls: ERC-20 specifics.
How to test your token
- Write tests: transfers, zero amounts, insufficient balance,
transferFromwithout allowance (how to test). - Fuzzing: the sum of all balances always equals
totalSupply. - Compare with the OpenZeppelin implementation.
- Order an audit before launching with real money.
What to read next
On to security: the catalogue of vulnerabilities. And to run your own tests - the Foundry tutorial.
Keep reading
- 3 · Under the hood
Non-standard tokens: USDT without a return value, fee-on-transfer and blacklists
What surprises ERC-20 tokens hide - USDT with no return value, transfer fees, address blocking, different decimals - and how an escrow contract defends itself.
5 min read - 2 · Practice
ERC-20 and approve: why the “approval” and what unlimited means
How ERC-20 tokens (USDT, USDC, DAI) work, why a separate approve signature is needed before depositing and how to manage approvals safely.
4 min read
Mentioned in
Ready to try it?
Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.

