Skip to main content

Type a word from a question or an article title.

3 · Under the hood

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.

5 min readUpdated:
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

solidity
// 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:

  1. The owner calls approve(spender, value). A record appears in the allowance table.
  2. 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 bool from transfer. Contracts must use SafeERC20, 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

  1. Write tests: transfers, zero amounts, insufficient balance, transferFrom without allowance (how to test).
  2. Fuzzing: the sum of all balances always equals totalSupply.
  3. Compare with the OpenZeppelin implementation.
  4. Order an audit before launching with real money.

On to security: the catalogue of vulnerabilities. And to run your own tests - the Foundry tutorial.

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.