# 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*