---
# System prepended metadata

title: 'ERC20 Deep Dive Analysis (1/n) - Core State Variables, Function Breakdown'

---

# ERC20 Deep Dive Analysis (1/n) - Core State Variables, Function Breakdown
> A line-by-line analysis of ERC20's core architecture, from state variables to internal functions. Written while learning Solidity.

## What's ERC20?
- A token standard on Ethereum that defines a common interface for fungible tokens
- It solves the interoperability problem by establishing a standardized set of function (transfer, approve, balanceOf etc.) that all tokens implement, allowing wallets, exchanges, and dApps to interact with tokens

## Core State Variables
### Internal Storage
- `_balances`: mapping(address account => uint256)
    - Purpose: Track token balance for each address
    - Why mapping?: O(1) lookup, scalable for millions of users
- `_allowances`: mapping(address account => mapping(address spender => uint256))
    - Purpose: Track how much address A allows address B to spend
    - Why need nested mapping?: Each owner can approve multiple spenders
    - Example: allowances[Alice][Bob] = 10, means Bob can spend 10 of Alice's tokens
- `_totalSupply`: uint256
    - Purpose: Track total tokens in existence
    - Invariant: sum of all balances == _totalSupply
- `_name`, `_symbol`. `_decimals`: Metadata
    - `_name`, `_symbol` are set once during construction, both values are immutable
    - `_decimals` is default set to 18, but can be overriden in derived contracts

## Function Breakdown
### 1. transfer(address to, uint256 amount)
- Purpose: Send tokens from msg.sender to recipient
- Implementation Flow
    ```solidity
    function transfer(address to, uint256 value) public virtual returns (bool) {
        address owner = _msgSender(); 
        _transfer(owner, to, value); // call internal function
        return true; // return true or revert
    }

    // In Context.sol, _msgSender() can be overriden in derived contracts to support meta-transaction/ gasless transaction
    function _msgSender() internal view virtual returns (address) {
        return msg.sender;
    }

    // internal _transfer function
    function _transfer(address from, address to, uint256 value) internal {
        if (from == address(0)) { // if sender address is 0
            revert ERC20InvalidSender(address(0)); // revert
        }
        if (to == address(0)) { // if receiver address is 0
            revert ERC20InvalidReceiver(address(0)); // revert
        }
        _update(from, to, value);
    }

    // internal _update function
    function _update(address from, address to, uint256 value) internal virtual {
        // if sender address is 0, it's a mint operation
        if (from == address(0)) {
            // Overflow check required: The rest of the code assumes that totalSupply never overflows
            _totalSupply += value; // overflow protection ensures _totalSupply never exceed type(uint256).max
        }
        else // if sender address isn't 0, it's a transfer/ burn existing token operation
        {
            uint256 fromBalance = _balances[from]; // get current balance from the sender
            if (fromBalance < value) { // ensure sender has enough tokens, otherwise revert
                revert ERC20InsufficientBalance(from, fromBalance, value);
            }
            unchecked {
                // Overflow not possible: value <= fromBalance <= totalSupply.
                _balances[from] = fromBalance - value; // substract tokens from sender's balance
            }
        }
        // if receiver address is 0, it's a burn operation
        if (to == address(0)) {
            unchecked {
                // Overflow not possible: value <= totalSupply or value <= fromBalance <= totalSupply.
                _totalSupply -= value; // decrease total supply by value
            }
        }
        else { // if receiver address isn't 0, it's a transfer operation
            unchecked {
                // Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256.
                _balances[to] += value; // increase receiver's balance by value
            }
        }
        // emit event, regardless of operation type
        // event Transfer(address indexed from, address indexed to, uint256 value);
        emit Transfer(from, to, value);
    }
    ```
- Checks
    - `to`, `from` cannot be address(0)
    - `from` must have enough balance
    - No overflow with Solidity 0.8+
- State changes
    - `_balances[from] -= value`
    - `_balances[to] += value`
    - Emit Transfer(from, to, value)
- Side Notes: Why using _msgSender instead of msg.sender() directly?
    - Normal transaction: User → pays gas → ERC20.transfer()
        - msg.sender = user's address
    - Meta transaction: User → signs message → Relayer pays gas → ERC20.transfer()
        - msg.sender = relayer's address ❌
        - _msgSender() = user's address ✅
    - Example
        ```solidity
        // Standard ERC20
        contract MyToken is ERC20 {
            function transfer(address to, uint256 value) public {
                address owner = _msgSender(); // Returns msg.sender
                _transfer(owner, to, value);
            }
        }

        // ERC20 with gasless transactions
        contract MyTokenGasless is ERC20, ERC2771Context {
            function _msgSender() internal view override(Context, ERC2771Context) 
                returns (address) {
                return ERC2771Context._msgSender(); // Extracts real sender's, not relayer's address
            }
            // transfer() still works without modification
        }
        ```

### 2. allowance (address owner, address spender)
- Purpose: Check how much a spender is authorized to spend on behalf of a token owner
- Implementation Flow
    ```solidity
    function allowance(address owner, address spender) public view virtual returns (uint256) {
        return _allowances[owner][spender];
    }
    ```
- Side Notes
    - It's a pure getter function - no gas cost when called externally
    - Return 0 as default value
    - Used internally by `_spendAllowance` to check permission before `transferFrom` to avoid reverts

### 3. approve (address spender, uint256 value)
- Purpose: Allow spender to withdraw from your account, up to amount
- Implementation Flow
    ```solidity
    function approve(address spender, uint256 value) public virtual returns (bool) {
        address owner = _msgSender();
        _approve(owner, spender, value);
        return true;
    }

    // internal _approve function
    // Wrapper: public approve - Must emit event
    function _approve(address owner, address spender, uint256 value) internal {
        _approve(owner, spender, value, true); // emitEvent flag = true
    }
    // Full implementation
    function _approve(address owner, address spender, uint256 value, bool emitEvent) internal virtual {
        if (owner == address(0)) { // if owner address is 0, revert
            revert ERC20InvalidApprover(address(0));
        }
        if (spender == address(0)) { // if spnder address is 0, revert
            revert ERC20InvalidSpender(address(0));
        }
        _allowances[owner][spender] = value; // update the nested mapping of allowance
        if (emitEvent) { // emitEvent flag is true
            emit Approval(owner, spender, value); // emit event
        }
    }
    ```
- Checks
    - `owner`, `spender` address cannot be address(0)
- State changes
    - `_allowances[owner][spender] = value`
    - Emit Approval(owner, spender, value)
- Side Notes
    - Might have race condition vulnerability (front-running)
    - Why owner's balance check is done at `transferFrom` not at `approve`
        - `approve()` is about permission, balance might change between approval and actual execution
        - `transferFrom()` is about execution, must verify the balance


### 4. transferFrom (address from, address to, uint256 value)
- Purpose: Transfer tokens on behalf of another address (requires approval)
- Implementation Flow
    ```solidity
    function transferFrom(address from, address to, uint256 value) public virtual returns (bool) {
        address spender = _msgSender();
        _spendAllowance(from, spender, value); // check & update allowance
        _transfer(from, to, value); // do the transfer
        return true;
    }

    // internal _spendAllowance function
    // update owner's allowance for spender, based on spent value
    function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
        // get current allowance the spender allowed to spend on behalf of the owner
        uint256 currentAllowance = allowance(owner, spender);
        // check if current allowance if NOT infinite
        if (currentAllowance < type(uint256).max) {
            // if current allowance is NOT enough, revert
            if (currentAllowance < value) {
                revert ERC20InsufficientAllowance(spender, currentAllowance, value);
            }
            // decrease the allowance by value amount
            unchecked {
                _approve(owner, spender, currentAllowance - value, false); // default NOT emit Approval event
            }
        }
        // if allowance = type(uint256).max, it's treated as infinite approval, don't update the allowance
    }
    ```
- Checks
    - `from`, `to` cannot be address(0)
    - balance[from] >= `value`
    - allowance[from][spender] >= `value`
- State changes
    - `_allowances[from][spender] -= value`: unless infinite approval
    - `_balances[from] -= value`
    - `_balances[to] += value`
    - Emit Transfer(from, to, value)
- Side Notes: Why the allowance won't get updated if the spender get infinite approval?
    - Inifinite approval is when an owner approves the max possible token amount to a spender, effectively give the spender unlimited permission to spend tokens
        ```solidity
        // Normal approval - limited amount
        token.approve(spender, 1000);  // Can spend up to 1000 tokens

        // Infinite approval - unlimited
        token.approve(spender, type(uint256).max);  // Can spend any amount
        ```
    - It's more gas efficient (approve once, trade forever), but comes with security tradeoff, as attackers can drain all the tokens if the approved contracts get hacked
    - Infinite approval is commonly used in legacy approval in Defi projects, now lots of projects switch to limited approvals
        ```
        What You See in Legacy Practice

        When you interact with any DEX, you'll see this flow:

        User clicks "Swap 100 USDC for ETH"
            ↓
        Wallet pops up: "Approve Uniswap to spend your USDC"
            ↓
        Behind the scenes: token.approve(uniswapRouter, type(uint256).max)
            ↓
        Every future swap: No approval needed!
        ```
### 5. _mint (address account, uint256 value)
- Purpose: Create new tokens
- Implementation Flow
    ```solidity
    function _mint(address account, uint256 value) internal {
        if (account == address(0)) {
            revert ERC20InvalidReceiver(address(0));
        }
        _update(address(0), account, value);
    }

    // internal _update function
    function _update(address from, address to, uint256 value) internal virtual {
        // if sender address is 0, it's a mint operation
        if (from == address(0)) {
            // Overflow check required: The rest of the code assumes that totalSupply never overflows
            _totalSupply += value; // overflow protection ensures _totalSupply never exceed type(uint256).max
        }
        else // if sender address isn't 0, it's a transfer/ burn existing token operation
        // ...
        // if receiver address is 0, it's a burn operation
        // ...
        else { // if receiver address isn't 0, it's a transfer operation
            unchecked {
                // Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256.
                _balances[to] += value; // increase receiver's balance by value
            }
        }
        // emit event, regardless of operation type
        // event Transfer(address indexed from, address indexed to, uint256 value);
        emit Transfer(from, to, value);
    }
    ```
- Checks
    - `account` cannot be address(0)
- State changes
    - `_totalSupply += value`
    - `_balances[account] += value`
    - Emit Transfer(address(0), account, value)
- Side Notes
    - `_mint` is not virtual, cannot be overridden, but `_update` can be updated for customized needs

### 6. _burn (address account, uint256 value)
- Purpose: Destroy toknes
- Implementation Flow
    ```solidity
    function _burn(address account, uint256 value) internal {
        if (account == address(0)) {
            revert ERC20InvalidSender(address(0));
        }
        _update(account, address(0), value);
    }

    // internal _update function
    function _update(address from, address to, uint256 value) internal virtual {
        // if sender address is 0, it's a mint operation
        // ...
        else // if sender address isn't 0, it's a transfer/ burn existing token operation
        {
            uint256 fromBalance = _balances[from]; // get current balance from the sender
            if (fromBalance < value) { // ensure sender has enough tokens, otherwise revert
                revert ERC20InsufficientBalance(from, fromBalance, value);
            }
            unchecked {
                // Overflow not possible: value <= fromBalance <= totalSupply.
                _balances[from] = fromBalance - value; // substract tokens from sender's balance
            }
        }
        // if receiver address is 0, it's a burn operation
        if (to == address(0)) {
            unchecked {
                // Overflow not possible: value <= totalSupply or value <= fromBalance <= totalSupply.
                _totalSupply -= value; // decrease total supply by value
            }
        }
        // else ...

        // emit event, regardless of operation type
        // event Transfer(address indexed from, address indexed to, uint256 value);
        emit Transfer(from, to, value);
    }
    ```
- Checks
    - `account` cannot be address(0)
    - `_balances[account] >= value`
- State changes
    - `_balances[account] -= value`
    - `_totalSupply -= value`
    - Emit Transfer(account, address(0), value)
- Side Notes
    - `_burn` is not virtual, cannot be overridden, but `_update` can be updated for customized needs

## Key Notes
1. ERC20 is a standard interface: Implementation can vary
2. Allowance enable Defi: Without it, there will be no DEX, lending etc.
3. Events are crucial: Wallets/ explorers rely on them
4. Internal function: Enable safe customization via inheritance
5. Simple but powerful: Only 6 functinos but power trillions in value

## Reference
- IERC20.sol: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.6/contracts/token/ERC20/IERC20.sol
- ERC20.sol: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol

## Relevant Blogs
- [ERC20 Deep Dive Analysis (2/n) - State Transition Flow, Security Analysis](https://hackmd.io/@ChloeIsCoding/ryihKdR6gx)

## Discussion
Found an error? Have questions? 
- Twitter: [@chloe_zhuX](https://x.com/Chloe_zhuX)
- Telegram: [@Chloe_zhu](https://t.me/chloe_zhu)
- GitHub: [@Chloezhu010](https://github.com/Chloezhu010)

---

*Last updated: Oct 15th, 2025*
*Part of my #LearnInPublic Solidity series*