SOURCE CODE

ERC20.sol

The core ERC-20 token implementation from OpenZeppelin. Every function explained.Because developers of the future should know the code they interact with.

Interactive Walkthrough
Solidity0.8.20
|
LicenseMIT
InheritsContextIERC20IERC20MetadataIERC20Errors
Lines316
Verified on Etherscan
ERC20.sol
1// SPDX-License-Identifier: MIT
2// OpenZeppelin Contracts (last updated v5.0.0) (token/ERC20/ERC20.sol)

MIT license and OpenZeppelin version tag. The path token/ERC20/ERC20.sol places this in OpenZeppelin's token module — the most-used module in the entire library.

3 
4pragma solidity ^0.8.20;

Same ^0.8.20 pragma as all other contracts in the EffortToken compilation unit. The 0.8.x series includes built-in overflow checks (since 0.8.0), which is why the unchecked blocks later in this file are safe — they're explicitly opting out of redundant checks.

5 
6import {IERC20} from "./IERC20.sol";
7import {IERC20Metadata} from "./extensions/IERC20Metadata.sol";
8import {Context} from "../../utils/Context.sol";
9import {IERC20Errors} from "../../interfaces/draft-IERC6093.sol";

Each import brings a distinct piece of the puzzle. IERC20: function signatures and events. IERC20Metadata: name/symbol/decimals. Context: _msgSender() abstraction. IERC20Errors: standardized revert errors. This 4-file separation is a hallmark of OpenZeppelin's modular architecture.

  • IERC20 — the 6 required functions + 2 events from EIP-20
  • IERC20Metadata — the 3 metadata functions (name, symbol, decimals)
  • Context — _msgSender() for meta-transaction support (ERC-2771)
  • IERC20Errors — the 6 EIP-6093 custom errors (InsufficientBalance, InvalidSender, etc.)

The ERC-20 Dependency Chain

ERC20.sol imports four dependencies that form the complete ERC-20 implementation stack. IERC20 defines the standard interface (transfer, approve, transferFrom signatures). IERC20Metadata adds name(), symbol(), and decimals(). Context provides _msgSender() for meta-transaction compatibility. IERC20Errors (from draft-IERC6093.sol) provides the standardized custom error types. Together, these four imports give ERC20.sol everything it needs to be a fully compliant, production-grade token implementation.

Why it matters

Understanding the import chain reveals how OpenZeppelin separates concerns: interface (IERC20), metadata (IERC20Metadata), utility (Context), and errors (IERC20Errors) are each in their own file. This separation means you can import just the interface for type-checking without pulling in the full implementation. EffortToken inherits this entire chain through a single `is ERC20` declaration.

Security Note

All four imports use the curly-brace destructuring syntax ({IERC20} from ...) which is the recommended Solidity import style. It makes explicit exactly which symbols are imported, preventing namespace pollution and making audit easier.

Tips & Tricks
  • The import path "../../utils/Context.sol" tells you ERC20 lives two directories deep: contracts/token/ERC20/ERC20.sol.
  • IERC20Errors comes from draft-IERC6093.sol — the same file we walked through in the IERC6093 walkthrough.
  • You can import just IERC20 (without ERC20) in other contracts that only need to interact with ERC-20 tokens, not implement one.
Common Mistakes
  • Importing the full ERC20 contract when you only need the IERC20 interface — this adds unnecessary bytecode.
  • Not realizing that ERC20 inherits Context — if you also inherit Context separately, you'll get a diamond inheritance conflict.
Key Terms
IERC20
The standard ERC-20 interface defining transfer(), approve(), transferFrom(), balanceOf(), totalSupply(), and allowance() function signatures plus Transfer and Approval events.
IERC20Metadata
Extension interface adding name(), symbol(), and decimals() — metadata functions not in the original ERC-20 standard but universally expected by wallets and dApps.
Context
Utility contract providing _msgSender() and _msgData() — abstractions over msg.sender and msg.data that enable meta-transaction compatibility (ERC-2771).
💡
Did You Know?

The original ERC-20 standard (EIP-20, written by Fabian Vogelsteller and Vitalik Buterin in 2015) didn't include name, symbol, or decimals. Those were added later as "optional" methods, but became so universally expected that OpenZeppelin made them a standard part of the implementation.

ERC20.sol
11/**
12 * @dev Implementation of the {IERC20} interface.
13 *
14 * This implementation is agnostic to the way tokens are created. This means
15 * that a supply mechanism has to be added in a derived contract using {_mint}.
16 *
17 * TIP: For a detailed writeup see our guide
18 * https://forum.openzeppelin.com/t/how-to-implement-erc20-supply-mechanisms/226[How
19 * to implement supply mechanisms].
20 *
21 * The default value of {decimals} is 18. To change this, you should override
22 * this function so it returns a different value.
23 *
24 * We have followed general OpenZeppelin Contracts guidelines: functions revert
25 * instead returning `false` on failure. This behavior is nonetheless
26 * conventional and does not conflict with the expectations of ERC20
27 * applications.
28 *
29 * Additionally, an {Approval} event is emitted on calls to {transferFrom}.
30 * This allows applications to reconstruct the allowance for all accounts just
31 * by listening to said events. Other implementations of the EIP may not emit
32 * these events, as it isn't required by the specification.
33 */

This 23-line documentation block is unusually long — reflecting how critical ERC-20 is to the ecosystem. Key takeaways: (1) Supply mechanism is YOUR responsibility, (2) decimals defaults to 18, (3) functions revert on failure (not return false), (4) Approval events are emitted on transferFrom for convenience.

  • "Agnostic to the way tokens are created" — ERC20 is a blank canvas for supply mechanics
  • "Revert instead of returning false" — matches the OpenZeppelin safety-first philosophy
  • "Approval event on transferFrom" — extra convenience not required by EIP-20
34abstract contract ERC20 is Context, IERC20, IERC20Metadata, IERC20Errors {

The contract declaration inherits from four parents. Context gives _msgSender(). IERC20 defines the standard interface. IERC20Metadata adds name/symbol/decimals. IERC20Errors provides EIP-6093 custom errors. This line is where EffortToken's entire ERC-20 capability originates.

  • Context → _msgSender(), _msgData()
  • IERC20 → transfer(), approve(), transferFrom(), balanceOf(), totalSupply(), allowance()
  • IERC20Metadata → name(), symbol(), decimals()
  • IERC20Errors → ERC20InsufficientBalance, ERC20InvalidSender, etc.

The ERC-20 Blueprint: Abstract by Design

The NatSpec documentation block explains key design decisions: this contract is "agnostic to the way tokens are created" — meaning it provides no public mint function. A derived contract (like EffortToken) must add supply mechanics using the internal _mint() function. The contract is declared abstract because it inherits interface functions it doesn't fully implement on its own. The 4-way inheritance (Context, IERC20, IERC20Metadata, IERC20Errors) is the most complex inheritance chain we've seen so far.

Why it matters

The abstract keyword is crucial — it means you CANNOT deploy ERC20 by itself. You must create a child contract that provides the missing pieces (like minting logic). This is exactly what EffortToken does: it extends ERC20 with mintTokensForTask(), spendTokensForContent(), and other application-specific functions. Understanding this pattern is essential for creating your own tokens.

Security Note

The NatSpec mentions that functions "revert instead of returning false on failure." This is a deliberate security choice — returning false silently is dangerous because callers might not check the return value. Reverting ensures failures are impossible to ignore.

Tips & Tricks
  • The abstract keyword means at least one function from the inherited interfaces has no implementation body. In practice, ERC20 implements everything, but the keyword allows _update() to be overridden.
  • Notice the Approval event convention: ERC20 emits it on transferFrom even though the EIP doesn't require it. This is OpenZeppelin being extra helpful — it lets indexers reconstruct allowance state from events alone.
  • The NatSpec link to the supply mechanisms guide is a real, working URL — it's worth reading for advanced token patterns.
Common Mistakes
  • Trying to deploy ERC20 directly without extending it — the abstract keyword prevents this at compile time.
  • Not understanding that ERC20 provides NO public mint function — you must add your own supply mechanism.
  • Assuming all ERC-20 implementations emit Approval on transferFrom — the EIP doesn't require it, only OpenZeppelin's implementation does.
Key Terms
abstract contract
A contract that cannot be deployed directly. Must be inherited by a non-abstract child contract. Used when the base contract defines a pattern but expects derived contracts to fill in details.
Multiple Inheritance
Solidity supports inheriting from multiple contracts using comma separation (is A, B, C). The C3 linearization algorithm determines the method resolution order.
💡
Did You Know?

The OpenZeppelin ERC20 contract has been deployed over 500,000 times on Ethereum mainnet alone. It's arguably the single most-deployed smart contract template in blockchain history.

ERC20.sol
35 mapping(address account => uint256) private _balances;

The core mapping: every Ethereum address maps to a uint256 token balance. This single mapping is the authoritative record of token ownership. When you call balanceOf(address), it simply returns _balances[address].

  • Named parameter syntax (address account =>) is documentation only — it doesn't affect the ABI
  • Default value is 0 — new addresses start with zero balance
  • Modified by _update() on every transfer, mint, and burn
36 
37 mapping(address account => mapping(address spender => uint256)) private _allowances;

A nested mapping implementing the ERC-20 approval mechanism. _allowances[owner][spender] tells you how many tokens the spender is authorized to transfer on behalf of the owner. This is the data structure behind approve() and transferFrom().

  • Two-level nesting: first key is the token owner, second key is the approved spender
  • Set by _approve(), consumed by _spendAllowance()
  • The infinite allowance pattern (type(uint256).max) means this value is never decremented
38 
39 uint256 private _totalSupply;

Tracks the total number of tokens in existence. Incremented on _mint(), decremented on _burn(). The sum of all _balances entries should always equal _totalSupply.

40 
41 string private _name;
42 string private _symbol;

Stored as strings (not bytes32) for flexibility. Set once in the constructor and never changed. For EffortToken, _name is "EffortToken" and _symbol is " EFFORT".

Five Storage Slots That Power Every ERC-20

These 5 state variables are the entire persistent state of an ERC-20 token. _balances maps every address to its token balance. _allowances is a nested mapping tracking how much each owner has approved each spender to transfer. _totalSupply tracks the aggregate supply. _name and _symbol store the token's human-readable identity. All are private — accessible only through public getter functions.

Why it matters

Every ERC-20 operation ultimately reads or writes these 5 variables. transfer() updates _balances. approve() updates _allowances. _mint() updates _balances and _totalSupply. Understanding this storage layout is the foundation for understanding gas costs, security, and how tokens actually work on-chain.

Security Note

All variables are private, not public. This is intentional — OpenZeppelin provides explicit getter functions (balanceOf, allowance, etc.) that can be overridden by child contracts. If these were public, Solidity would auto-generate getters that couldn't be customized.

Tips & Tricks
  • The mapping syntax `mapping(address account => uint256)` uses Solidity 0.8.18+ named parameters — the 'account' label is purely for documentation.
  • The nested _allowances mapping reads as: _allowances[owner][spender] = amount. Think of it as: "owner allows spender to use this many tokens."
  • These 5 variables use 5 storage slots. At ~20,000 gas per SSTORE, the cost of updating state is the primary expense in ERC-20 operations.
Common Mistakes
  • Trying to access _balances directly from a child contract — it's private. Use balanceOf() instead, or override _update() if you need to modify balance logic.
  • Not understanding that mappings in Solidity default to 0 for unset keys — a new address has a balance of 0 without any explicit initialization.
Key Terms
mapping
A hash-table-like data structure in Solidity. Keys are hashed with keccak256 to determine storage slot positions. All possible keys exist with a default value of 0.
private
The most restrictive visibility. Private state variables can only be accessed from within the same contract — not even child contracts can read them directly. However, all blockchain data is publicly readable off-chain.
💡
Did You Know?

Although _balances is marked private, nothing on the blockchain is truly hidden. Anyone can read storage slot values directly using eth_getStorageAt. The private keyword only restricts Solidity-level access, not blockchain-level visibility.

ERC20.sol
44 /**
45 * @dev Sets the values for {name} and {symbol}.
46 *
47 * All two of these values are immutable: they can only be set once during
48 * construction.
49 */
50 constructor(string memory name_, string memory symbol_) {
51 _name = name_;
52 _symbol = symbol_;
53 }

Called exactly once at deployment. Stores the name and symbol permanently. For EffortToken, this is called with ("EffortToken", " EFFORT") from the child constructor. The NatSpec says "immutable" but technically they're just never-modified state variables (not using the immutable keyword).

  • Trailing underscore convention (name_) prevents variable shadowing
  • Constructor parameters are passed from the child contract: contract EffortToken is ERC20("EffortToken", " EFFORT")
54 
55 /**
56 * @dev Returns the name of the token.
57 */
58 function name() public view virtual returns (string memory) {
59 return _name;
60 }

Simple getter returning _name. For EffortToken this returns "EffortToken". Marked virtual so child contracts could override it (e.g., to return a different name based on conditions), though this is rarely done.

61 
62 /**
63 * @dev Returns the symbol of the token, usually a shorter version of the
64 * name.
65 */
66 function symbol() public view virtual returns (string memory) {
67 return _symbol;
68 }

Returns the short trading symbol. For EffortToken this returns " EFFORT". This is what wallets display next to balances and what exchanges use as the ticker.

69 
70 /**
71 * @dev Returns the number of decimals used to get its user representation.
72 * For example, if `decimals` equals `2`, a balance of `505` tokens should
73 * be displayed to a user as `5.05` (`505 / 10 ** 2`).
74 *
75 * Tokens usually opt for a value of 18, imitating the relationship between
76 * Ether and Wei. This is the default value returned by this function, unless
77 * it's overridden.
78 *
79 * NOTE: This information is only used for _display_ purposes: it in
80 * no way affects any of the arithmetic of the contract, including
81 * {IERC20-balanceOf} and {IERC20-transfer}.
82 */
83 function decimals() public view virtual returns (uint8) {
84 return 18;
85 }

Returns 18 — the universal default. This tells frontends: divide the raw uint256 balance by 10^18 to get the human-readable amount. The extensive NatSpec emphasizes this is ONLY for display. The contract does all math in raw units.

  • Returns uint8, not uint256 — maximum possible value is 255
  • Has NO effect on contract arithmetic — zero, nada, zilch
  • Override this function to use a different decimal count (e.g., return 6 for a stablecoin)

Birth of a Token: Name, Symbol, and the 18-Decimal Convention

The constructor takes name and symbol as parameters and stores them permanently. The three metadata functions — name(), symbol(), and decimals() — return this identity data. decimals() is particularly important: it returns 18 by default, meaning 1 token is represented as 1000000000000000000 (10^18) in raw uint256 form. This mirrors the ETH/Wei relationship and is the universal convention for ERC-20 tokens.

Why it matters

decimals() is a constant source of confusion for new developers. It does NOT affect the contract's arithmetic at all — the contract always works in raw integer units. decimals() is purely a display hint for frontends. If you see a balance of 5000000000000000000 and decimals is 18, display "5.0" to the user. Getting this wrong leads to showing absurd numbers in your UI.

Security Note

All three metadata functions are marked virtual, meaning child contracts can override them. Some malicious tokens override name() or symbol() to impersonate legitimate tokens. Always verify token identity by contract address, never by name/symbol alone.

Tips & Tricks
  • The constructor parameters use trailing underscores (name_, symbol_) to avoid shadowing the state variables _name and _symbol.
  • decimals() returns uint8 (not uint256) because 255 decimal places is more than enough. Most tokens use 18; stablecoins like USDC use 6.
  • All three functions are view (read-only) and cost zero gas when called externally from off-chain code.
Common Mistakes
  • Multiplying or dividing by 10**decimals() in your Solidity code — the contract already works in the smallest unit. Only apply decimals formatting in your frontend.
  • Assuming all tokens have 18 decimals — USDC has 6, WBTC has 8. Always check decimals() before displaying balances.
  • Trying to change the name or symbol after deployment — they're set in the constructor and immutable.
Key Terms
decimals
A display hint telling frontends how many decimal places to show. 18 means 1 token = 10^18 raw units. Has zero effect on contract arithmetic — purely cosmetic.
virtual
A function modifier allowing child contracts to override the function's behavior. Without virtual, the function is sealed and cannot be changed by inheritors.
💡
Did You Know?

The number 18 was chosen for decimals because 1 ETH = 10^18 Wei, and early ERC-20 tokens wanted to match this convention. USDC chose 6 decimals because the US dollar has 2 decimal places and they wanted some extra precision without the gas cost of handling 18-digit numbers.

ERC20.sol
87 /**
88 * @dev See {IERC20-totalSupply}.
89 */
90 function totalSupply() public view virtual returns (uint256) {
91 return _totalSupply;
92 }

Returns _totalSupply, the aggregate count of all tokens. This number increases on mint and decreases on burn. For EffortToken, it starts at 0 and grows as students complete quizzes and earn tokens.

93 
94 /**
95 * @dev See {IERC20-balanceOf}.
96 */
97 function balanceOf(address account) public view virtual returns (uint256) {
98 return _balances[account];
99 }

Looks up _balances[account] and returns the result. This is the function your wallet calls to show your EFFORT balance. New addresses return 0 because Solidity mappings default to zero.

Reading the Ledger: Gas-Free Token Queries

totalSupply() and balanceOf() are the simplest functions in the contract — they just return state variables. totalSupply() returns the aggregate number of tokens in existence. balanceOf(account) returns how many tokens a specific address owns. Both are view functions, meaning they only read state and never modify it.

Why it matters

These are the functions your frontend calls most frequently. Every wallet, portfolio tracker, and DeFi dashboard uses balanceOf() to show token holdings. Because they're view functions, calling them from off-chain (e.g., wagmi's useReadContract) costs zero gas. They're the read-only window into the token ledger.

Tips & Tricks
  • View functions cost 0 gas when called externally (off-chain). They only cost gas when called internally from a state-changing transaction.
  • balanceOf() returns raw units. With 18 decimals, a return value of 1000000000000000000 means 1.0 tokens.
  • Both functions are virtual — technically overridable, though overriding balanceOf would be extremely unusual and potentially dangerous.
Common Mistakes
  • Caching balanceOf results in your frontend without refreshing — balances change with every transfer. Use wagmi's watch mode or event listeners.
  • Assuming totalSupply is fixed — it changes on every mint and burn operation.
Key Terms
view
A function modifier guaranteeing the function only reads state, never writes it. View functions cost zero gas when called externally from off-chain code.
💡
Did You Know?

balanceOf() is the most-called function in all of Ethereum. Every block explorer page load, every wallet refresh, every DeFi protocol interaction starts with a balanceOf() call. It executes billions of times per day across all EVM chains.

ERC20.sol
101 /**
102 * @dev See {IERC20-transfer}.
103 *
104 * Requirements:
105 *
106 * - `to` cannot be the zero address.
107 * - the caller must have a balance of at least `value`.
108 */

Two requirements documented: recipient cannot be address(0), and caller must have sufficient balance. These checks happen inside _transfer() and _update() — not in this public wrapper.

109 function transfer(address to, uint256 value) public virtual returns (bool) {
110 address owner = _msgSender();
111 _transfer(owner, to, value);
112 return true;
113 }

Three lines of logic: (1) get the caller via _msgSender(), (2) delegate to _transfer(), (3) return true. The public function is intentionally simple — all validation and state changes happen in the internal functions.

  • address owner = _msgSender() — captures the caller's address
  • _transfer(owner, to, value) — does zero-address checks, then calls _update()
  • return true — always succeeds (failures revert before reaching this line)

The Most-Used ERC-20 Function

transfer() is the primary way users send tokens. It takes a recipient address and an amount, then moves tokens from the caller to the recipient. Internally, it uses _msgSender() (from Context) to identify the caller — this is more flexible than using msg.sender directly because it supports meta-transactions. The function delegates all heavy lifting to _transfer(), keeping the public function as a thin wrapper.

Why it matters

This is THE function your dApp calls when a user sends EFFORT tokens to another address. Understanding the flow (transfer → _transfer → _update) is key to understanding how balances actually change. The return value is always true (failures revert rather than returning false), following OpenZeppelin's safety-first convention.

Security Note

transfer() always returns true. If the transfer fails (insufficient balance, zero address, etc.), it reverts entirely — it never returns false. This means you don't need to check the return value when calling transfer on an OpenZeppelin token. However, some non-standard tokens DO return false, which is why libraries like SafeERC20 exist.

Tips & Tricks
  • _msgSender() is used instead of msg.sender for meta-transaction compatibility. In normal usage, _msgSender() returns msg.sender. In a meta-transaction context (ERC-2771), it returns the original signer.
  • The function returns bool for EIP-20 compatibility, but in this implementation it always returns true (or reverts).
  • This is a thin wrapper — transfer → _transfer → _update is the actual call chain.
Common Mistakes
  • Checking if transfer() returns false — in OpenZeppelin's implementation it never does. It reverts on failure.
  • Using msg.sender instead of _msgSender() in a child contract — this breaks meta-transaction support.
Key Terms
_msgSender()
Inherited from Context. Returns the transaction's sender address. Equivalent to msg.sender in standard transactions, but returns the original signer in meta-transaction (ERC-2771) contexts.
💡
Did You Know?

The ERC-20 transfer() function is the third most-called function on Ethereum mainnet (after ETH transfers and Uniswap swaps). Billions of dollars worth of tokens are moved through this exact code path every day.

ERC20.sol
115 /**
116 * @dev See {IERC20-allowance}.
117 */
118 function allowance(address owner, address spender) public view virtual returns (uint256) {
119 return _allowances[owner][spender];
120 }

Returns _allowances[owner][spender] — how many tokens the owner has approved the spender to transfer. Returns 0 if no approval exists. View function, zero gas cost off-chain.

121 
122 /**
123 * @dev See {IERC20-approve}.
124 *
125 * NOTE: If `value` is the maximum `uint256`, the allowance is not updated on
126 * `transferFrom`. This is semantically equivalent to an infinite approval.
127 *
128 * Requirements:
129 *
130 * - `spender` cannot be the zero address.
131 */
132 function approve(address spender, uint256 value) public virtual returns (bool) {
133 address owner = _msgSender();
134 _approve(owner, spender, value);
135 return true;
136 }

The token owner calls this to authorize a spender. The NatSpec highlights the infinite approval feature: setting value to type(uint256).max creates a permanent, non-decreasing allowance. Internally delegates to _approve() which validates addresses and emits the Approval event.

  • approve(spender, 100) means: "I allow spender to move up to 100 of MY tokens"
  • Calling approve again REPLACES the previous allowance (not additive)
  • The Approval event is emitted for off-chain tracking and indexing
137 
138 /**
139 * @dev See {IERC20-transferFrom}.
140 *
141 * Emits an {Approval} event indicating the updated allowance. This is not
142 * required by the EIP. See the note at the beginning of {ERC20}.
143 *
144 * NOTE: Does not update the allowance if the current allowance
145 * is the maximum `uint256`.
146 *
147 * Requirements:
148 *
149 * - `from` and `to` cannot be the zero address.
150 * - `from` must have a balance of at least `value`.
151 * - the caller must have allowance for ``from``'s tokens of at least
152 * `value`.
153 */
154 function transferFrom(address from, address to, uint256 value) public virtual returns (bool) {
155 address spender = _msgSender();
156 _spendAllowance(from, spender, value);
157 _transfer(from, to, value);
158 return true;
159 }

Called by the spender (not the token owner) to move tokens on the owner's behalf. First calls _spendAllowance() to verify and deduct the allowance, then calls _transfer() to move the tokens. The order matters: allowance check before transfer prevents reentrancy.

  • spender = _msgSender() — the caller is the spender, not the token owner
  • _spendAllowance(from, spender, value) — checks and deducts the allowance
  • _transfer(from, to, value) — moves the tokens (same as a regular transfer)
  • Returns true (or reverts) — same convention as transfer()

The Approval Dance: Delegated Token Transfers

These three functions implement the ERC-20 approval mechanism. The flow is: (1) Token owner calls approve(spender, amount) to grant permission. (2) Anyone can call allowance(owner, spender) to check the approved amount. (3) Spender calls transferFrom(from, to, amount) to move tokens on behalf of the owner. This three-step dance is essential for DeFi — it's how Uniswap, lending protocols, and EffortToken's ContentStore can move tokens without the owner signing every transaction.

Why it matters

Without the approval mechanism, every token movement would require the owner to directly call transfer(). The approve/transferFrom pattern enables smart contract automation — you approve a DEX once, and it can execute trades on your behalf. In EffortToken, users approve the ContentStore to spend their tokens, then the ContentStore calls transferFrom to process purchases.

Security Note

The infinite approval pattern (approve with type(uint256).max) means the spender can transfer any amount, any number of times, without the allowance ever decreasing. While convenient, this is a security risk if the approved contract is compromised. Consider using exact amounts instead of infinite approvals for maximum safety.

Tips & Tricks
  • The NatSpec says infinite approval (type(uint256).max) won't decrease on transferFrom — this is a gas optimization. No need to re-approve for every transaction.
  • approve() uses _msgSender() just like transfer() — the caller is the token owner granting permission.
  • transferFrom() calls _spendAllowance BEFORE _transfer — check allowance, deduct it, then move tokens. This ordering prevents reentrancy attacks.
Common Mistakes
  • Calling transferFrom without first having the owner call approve — you'll get ERC20InsufficientAllowance.
  • Not understanding that approve() REPLACES the allowance, it doesn't ADD to it. approve(spender, 100) followed by approve(spender, 50) sets the allowance to 50, not 150.
  • The front-running attack on approve: if you change an allowance from 100 to 50, a malicious spender could use the old 100 AND the new 50. Use increaseAllowance/decreaseAllowance patterns (not in base ERC20) to avoid this.
Key Terms
Allowance
The amount of tokens an owner has authorized a spender to transfer on their behalf. Stored in the _allowances nested mapping. Set by approve(), consumed by transferFrom().
Infinite Approval
Setting allowance to type(uint256).max (~1.15 × 10^77). When _spendAllowance detects this value, it skips the deduction — the allowance never decreases, saving gas on subsequent transferFrom calls.
transferFrom
A delegated transfer: the spender moves tokens from the owner to a recipient, consuming the pre-approved allowance. Essential for DeFi protocols that need to move user tokens.
💡
Did You Know?

The approve/transferFrom pattern was one of the most controversial design decisions in ERC-20. It requires two transactions (approve + transferFrom) where one could suffice. ERC-777 tried to fix this with operator permissions, but it introduced reentrancy vulnerabilities (the infamous reentrancy hook attack). ERC-20's two-step process remains the standard because simplicity wins.

ERC20.sol
161 /**
162 * @dev Moves a `value` amount of tokens from `from` to `to`.
163 *
164 * This internal function is equivalent to {transfer}, and can be used to
165 * e.g. implement automatic token fees, slashing mechanisms, etc.
166 *
167 * Emits a {Transfer} event.
168 *
169 * NOTE: This function is not virtual, {_update} should be overridden instead.
170 */

The NatSpec explicitly says: "This function is not virtual, _update should be overridden instead." This is a key v5 design decision — all transfer customization goes through _update(), ensuring the zero-address guards in _transfer() can never be accidentally bypassed.

171 function _transfer(address from, address to, uint256 value) internal {
172 if (from == address(0)) {
173 revert ERC20InvalidSender(address(0));
174 }
175 if (to == address(0)) {
176 revert ERC20InvalidReceiver(address(0));
177 }
178 _update(from, to, value);
179 }

Three operations: (1) Check from != address(0), revert with ERC20InvalidSender if so. (2) Check to != address(0), revert with ERC20InvalidReceiver if so. (3) Call _update(from, to, value) for the actual balance changes. Clean, simple, unoverridable.

  • if (from == address(0)) revert — prevents disguising mints as transfers
  • if (to == address(0)) revert — prevents disguising burns as transfers
  • _update(from, to, value) — where the actual math happens

The Guard at the Gate: Zero-Address Validation

_transfer() is the internal function called by both transfer() and transferFrom(). Its sole purpose is to validate that neither the sender nor receiver is the zero address, then delegate to _update() for actual balance changes. Crucially, this function is NOT virtual — you cannot override it. If you need to customize transfer behavior (fees, hooks, restrictions), override _update() instead.

Why it matters

This function enforces a critical invariant: normal transfers cannot come from or go to address(0). Transfers from address(0) are minting; transfers to address(0) are burning. By separating these concerns, _transfer() ensures that transfer() and transferFrom() can never accidentally mint or burn tokens.

Security Note

_transfer() is deliberately not virtual. OpenZeppelin made this design choice in v5 to centralize all customization in _update(). This prevents a common mistake where developers override _transfer() and forget to include the zero-address checks, creating a critical vulnerability.

Tips & Tricks
  • The "not virtual" design is new in OpenZeppelin v5. In v4, _transfer was virtual and many contracts overrode it. The v5 pattern of centralizing everything in _update() is cleaner and safer.
  • The ERC20InvalidSender and ERC20InvalidReceiver errors come from the IERC6093 interface we walked through earlier.
  • If you need to add transfer restrictions (pausing, blacklists, etc.), override _update() — not _transfer().
Common Mistakes
  • Trying to override _transfer() to add custom logic — it's not virtual in v5. Use _update() instead.
  • Not understanding why address(0) is rejected: it's to separate transfer logic from mint/burn logic.
Key Terms
internal
A function visibility that allows access from the current contract and all child contracts, but not from external calls. Internal functions are more gas-efficient than public ones because they use JUMP instead of CALL.
💡
Did You Know?

The zero address (0x0000000000000000000000000000000000000000) has received over $1 billion worth of tokens on Ethereum mainnet — all from burn operations. It's the most "wealthy" address on most token contracts, despite being uncontrollable.

ERC20.sol
181 /**
182 * @dev Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from`
183 * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding
184 * this function.
185 *
186 * Emits a {Transfer} event.
187 */

The documentation explains the dual nature: this function handles transfers normally, but doubles as the mint/burn mechanism when from or to is address(0). The instruction is clear: "All customizations to transfers, mints, and burns should be done by overriding this function."

188 function _update(address from, address to, uint256 value) internal virtual {
189 if (from == address(0)) {
190 // Overflow check required: The rest of the code assumes that totalSupply never overflows
191 _totalSupply += value;
192 } else {
193 uint256 fromBalance = _balances[from];
194 if (fromBalance < value) {
195 revert ERC20InsufficientBalance(from, fromBalance, value);
196 }
197 unchecked {
198 // Overflow not possible: value <= fromBalance <= totalSupply.
199 _balances[from] = fromBalance - value;
200 }
201 }

If from is address(0), this is a mint: increase _totalSupply (checked for overflow). Otherwise, debit the sender: check balance >= value, then subtract in an unchecked block (safe because value <= fromBalance).

  • Mint path: _totalSupply += value — the ONLY checked arithmetic in the function
  • Transfer path: fromBalance < value → revert ERC20InsufficientBalance
  • unchecked subtraction: _balances[from] -= value — safe because value <= fromBalance
202 
203 if (to == address(0)) {
204 unchecked {
205 // Overflow not possible: value <= totalSupply or value <= fromBalance <= totalSupply.
206 _totalSupply -= value;
207 }
208 } else {
209 unchecked {
210 // Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256.
211 _balances[to] += value;
212 }
213 }

If to is address(0), this is a burn: decrease _totalSupply in unchecked (safe because value was just deducted from a balance that was <= totalSupply). Otherwise, credit the receiver: add value to their balance in unchecked (safe because the sum <= totalSupply).

  • Burn path: _totalSupply -= value — safe because value <= totalSupply
  • Credit path: _balances[to] += value — safe because sum <= totalSupply
  • Both use unchecked for gas savings (~200 gas per arithmetic operation)
214 
215 emit Transfer(from, to, value);
216 }

Every execution path — mint, burn, or transfer — ends with this event. Block explorers use from=address(0) to identify mints and to=address(0) to identify burns. This single event emission handles all three cases.

The Heart of ERC-20: One Function to Rule Them All

_update() is the most important function in the entire ERC-20 contract. It handles ALL balance changes — transfers, minting, and burning — in a single unified function. If from is address(0), it's a mint (increase totalSupply). If to is address(0), it's a burn (decrease totalSupply). Otherwise, it's a transfer (deduct from sender, credit to receiver). This centralization is the defining design pattern of OpenZeppelin v5.

Why it matters

This is THE function to override if you want to customize token behavior. Want to add transfer fees? Override _update(). Want to pause transfers? Override _update(). Want to emit custom events? Override _update(). Every mint, burn, and transfer flows through this single chokepoint, so one override captures all token movements. EffortToken doesn't override _update() — it uses the default behavior.

Security Note

The unchecked blocks are safe because of carefully reasoned overflow invariants. For the deduction (_balances[from] -= value): value <= fromBalance because of the preceding check. For _totalSupply -= value on burn: value <= totalSupply because it was first deducted from a balance. For _balances[to] += value: the sum is at most totalSupply which fits in uint256. These comments aren't just documentation — they're mathematical proofs of safety.

Tips & Tricks
  • The virtual keyword on _update() is the single customization hook for all ERC-20 behavior in v5. Override this one function to add: transfer taxes, hooks, pausing, snapshot logic, etc.
  • The minting path (from == address(0)) uses a checked add for _totalSupply — this is the ONE place where overflow IS possible if someone mints too many tokens.
  • The Transfer event is emitted with the original from/to addresses — including address(0) for mints and burns. This is how block explorers distinguish mints from transfers.
Common Mistakes
  • Adding transfer fees without calling super._update() — you'll break the balance accounting entirely.
  • Not understanding unchecked blocks — they're not unsafe here because the preceding logic guarantees no overflow/underflow.
  • Forgetting to emit Transfer in a custom override — the event is critical for off-chain indexing.
Key Terms
unchecked
A Solidity block that disables automatic overflow/underflow checks (introduced in 0.8.0). Used for gas optimization when the developer can mathematically prove the operation is safe.
Transfer event
The EIP-20 standard event emitted on every token movement. Parameters: from, to, value. When from is address(0), it's a mint. When to is address(0), it's a burn.
💡
Did You Know?

Before OpenZeppelin v5, transfer customization required overriding multiple functions (_beforeTokenTransfer, _afterTokenTransfer, _transfer, _mint, _burn). The v5 _update() pattern reduced this to a single override point — one of the cleanest breaking changes in the library's history.

ERC20.sol
218 /**
219 * @dev Creates a `value` amount of tokens and assigns them to `account`, by transferring it from address(0).
220 * Relies on the `_update` mechanism
221 *
222 * Emits a {Transfer} event with `from` set to the zero address.
223 *
224 * NOTE: This function is not virtual, {_update} should be overridden instead.
225 */
226 function _mint(address account, uint256 value) internal {
227 if (account == address(0)) {
228 revert ERC20InvalidReceiver(address(0));
229 }
230 _update(address(0), account, value);
231 }

Guard: account cannot be address(0) — you must mint TO a real address. Then calls _update(address(0), account, value). The address(0) as 'from' tells _update() to increase totalSupply instead of deducting from a sender's balance.

  • revert ERC20InvalidReceiver(address(0)) — prevents minting to the void
  • _update(address(0), account, value) — the address(0) 'from' triggers the mint path in _update
  • EffortToken calls this in mintTokensForTask() with access control: only the TaskRegistry can mint
232 
233 /**
234 * @dev Destroys a `value` amount of tokens from `account`, lowering the total supply.
235 * Relies on the `_update` mechanism.
236 *
237 * Emits a {Transfer} event with `to` set to the zero address.
238 *
239 * NOTE: This function is not virtual, {_update} should be overridden instead
240 */
241 function _burn(address account, uint256 value) internal {
242 if (account == address(0)) {
243 revert ERC20InvalidSender(address(0));
244 }
245 _update(account, address(0), value);
246 }

Guard: account cannot be address(0) — you must burn FROM a real address. Then calls _update(account, address(0), value). The address(0) as 'to' tells _update() to decrease totalSupply instead of crediting a receiver's balance.

  • revert ERC20InvalidSender(address(0)) — prevents burning from the void
  • _update(account, address(0), value) — the address(0) 'to' triggers the burn path in _update
  • EffortToken calls this in spendTokensForContent() to deduct tokens for premium content access

Creating and Destroying Tokens

_mint() creates new tokens by calling _update(address(0), account, value) — transferring from the zero address. _burn() destroys tokens by calling _update(account, address(0), value) — transferring to the zero address. Both are thin wrappers around _update() with a single zero-address guard each. Neither is virtual — if you need to customize minting or burning, override _update().

Why it matters

These are the functions EffortToken calls directly. mintTokensForTask() calls _mint() to reward students. spendTokensForContent() calls _burn() to consume tokens for premium content. Understanding that mint/burn are just special cases of _update() with address(0) reveals the elegant symmetry of the ERC-20 design.

Security Note

_mint() guards against minting TO address(0) (which would be a no-op — creating tokens that go nowhere). _burn() guards against burning FROM address(0) (which is meaningless — you can't destroy tokens from a non-existent account). These guards ensure both functions are semantically correct.

Tips & Tricks
  • Notice the asymmetry: _mint checks for InvalidReceiver (to cannot be 0), _burn checks for InvalidSender (from cannot be 0). This matches the semantics of what each operation does.
  • Neither function is virtual — you can't override them. This is intentional: override _update() instead to customize all token movements in one place.
  • The Transfer event is emitted by _update() with from=address(0) for mints and to=address(0) for burns.
Common Mistakes
  • Calling _mint() directly from an external function without access control — anyone could mint unlimited tokens. Always add onlyOwner or similar guards (like EffortToken does with onlyTaskRegistry).
  • Thinking _burn() removes tokens from the contract's balance — it removes them from the specified account. There's no implicit 'contract balance' in ERC-20.
Key Terms
_mint()
Creates new tokens by transferring from address(0). Increases _totalSupply and the recipient's balance. Called by EffortToken.mintTokensForTask() when students complete quizzes.
_burn()
Destroys tokens by transferring to address(0). Decreases _totalSupply and the account's balance. Called by EffortToken.spendTokensForContent() for premium content purchases.
💡
Did You Know?

The address(0) convention for minting and burning was not in the original ERC-20 specification. It emerged as a community convention because the Transfer event needed from/to addresses, and address(0) was the natural choice for "from nowhere" (mint) and "to nowhere" (burn).

ERC20.sol
248 /**
249 * @dev Sets `value` as the allowance of `spender` over the `owner` s tokens.
250 *
251 * This internal function is equivalent to `approve`, and can be used to
252 * e.g. set automatic allowances for certain subsystems, etc.
253 *
254 * Emits an {Approval} event.
255 *
256 * Requirements:
257 *
258 * - `owner` cannot be the zero address.
259 * - `spender` cannot be the zero address.
260 *
261 * Overrides to this logic should be done to the variant with an additional `bool emitEvent` argument.
262 */
263 function _approve(address owner, address spender, uint256 value) internal {
264 _approve(owner, spender, value, true);
265 }

Convenience function that calls the 4-parameter version with emitEvent=true. This is what approve() calls. Not virtual — override the 4-parameter version instead.

266 
267 /**
268 * @dev Variant of {_approve} with an optional flag to enable or disable the {Approval} event.
269 *
270 * By default (when calling {_approve}) the flag is set to true. On the other hand, approval changes made by
271 * `_spendAllowance` during the `transferFrom` operation set the flag to false. This saves gas by not emitting any
272 * `Approval` event during `transferFrom` operations.
273 *
274 * Anyone who wishes to continue emitting `Approval` events on the`transferFrom` operation can force the flag to
275 * true using the following override:
276 * ```
277 * function _approve(address owner, address spender, uint256 value, bool) internal virtual override {
278 * super._approve(owner, spender, value, true);
279 * }
280 * ```
281 *
282 * Requirements are the same as {_approve}.
283 */

Extensive documentation explaining why the emitEvent flag exists. Key insight: _spendAllowance passes false to skip the Approval event during transferFrom, saving gas. The NatSpec even includes an override example for projects that want events on every operation.

284 function _approve(address owner, address spender, uint256 value, bool emitEvent) internal virtual {
285 if (owner == address(0)) {
286 revert ERC20InvalidApprover(address(0));
287 }
288 if (spender == address(0)) {
289 revert ERC20InvalidSpender(address(0));
290 }
291 _allowances[owner][spender] = value;
292 if (emitEvent) {
293 emit Approval(owner, spender, value);
294 }
295 }

Four operations: (1) Validate owner != address(0). (2) Validate spender != address(0). (3) Set _allowances[owner][spender] = value. (4) If emitEvent, emit Approval(owner, spender, value). This is the virtual function to override for custom approval behavior.

  • revert ERC20InvalidApprover — prevents approvals from address(0)
  • revert ERC20InvalidSpender — prevents approvals to address(0)
  • _allowances[owner][spender] = value — the actual state change
  • emit Approval — conditional on emitEvent flag

Two Overloads, One Purpose: Managing Permissions

_approve() has two overloads. The 3-parameter version is a convenience wrapper that calls the 4-parameter version with emitEvent=true. The 4-parameter version is where the real logic lives: validate owner and spender aren't address(0), set the allowance, and optionally emit the Approval event. The emitEvent flag exists as a gas optimization — when _spendAllowance() deducts an allowance during transferFrom(), it doesn't need to emit an event for every deduction.

Why it matters

The emitEvent optimization is a subtle but important gas saving. During transferFrom(), the allowance is deducted via _spendAllowance → _approve(owner, spender, newValue, false). Skipping the Approval event saves ~2,000 gas per transferFrom() call. The NatSpec even shows how to override this behavior if you want events on every transferFrom.

Security Note

The zero-address guards prevent two dangerous scenarios: (1) Creating an allowance from address(0) — no one controls it, so the allowance would be useless and confusing. (2) Approving address(0) as a spender — creating a permission for an uncontrollable address.

Tips & Tricks
  • The 4-parameter overload is virtual — this is the one to override if you need to customize approval behavior.
  • The emitEvent=false case is used by _spendAllowance() to avoid redundant Approval events during transferFrom().
  • The NatSpec includes a code example showing how to force Approval events on transferFrom — a rare but useful pattern for applications that need full event logs.
Common Mistakes
  • Overriding the 3-parameter _approve instead of the 4-parameter version — the 3-parameter version is just a wrapper and isn't virtual.
  • Not emitting the Approval event in a custom override — off-chain indexers depend on it to track allowance state.
Key Terms
Function Overloading
Solidity allows multiple functions with the same name but different parameter types. The compiler disambiguates based on the arguments passed at the call site.
Approval event
EIP-20 standard event: Approval(owner, spender, value). Emitted when an allowance is set. Used by indexers to reconstruct the full allowance state from event logs.
💡
Did You Know?

The emitEvent optimization was added in OpenZeppelin v5. In v4, every transferFrom emitted both a Transfer event AND an Approval event. Removing the redundant Approval event saves ~2,000 gas per transferFrom — across millions of daily transactions, this optimization saves hundreds of ETH in gas fees daily.

ERC20.sol
297 /**
298 * @dev Updates `owner` s allowance for `spender` based on spent `value`.
299 *
300 * Does not update the allowance value in case of infinite allowance.
301 * Revert if not enough allowance is available.
302 *
303 * Does not emit an {Approval} event.
304 */

Three key behaviors documented: (1) Infinite allowances are not decremented. (2) Reverts if allowance is insufficient. (3) Does NOT emit an Approval event (handled by emitEvent=false in _approve).

305 function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
306 uint256 currentAllowance = allowance(owner, spender);
307 if (currentAllowance != type(uint256).max) {
308 if (currentAllowance < value) {
309 revert ERC20InsufficientAllowance(spender, currentAllowance, value);
310 }
311 unchecked {
312 _approve(owner, spender, currentAllowance - value, false);
313 }
314 }
315 }
316}

First reads the current allowance. If it equals type(uint256).max, skips everything (infinite approval). Otherwise: check allowance >= value (revert with ERC20InsufficientAllowance if not), then set new allowance to currentAllowance - value via _approve(... false). The closing brace on line 316 ends the entire ERC20 contract.

  • currentAllowance = allowance(owner, spender) — reads from the public getter
  • != type(uint256).max — the infinite allowance gate: skip everything if infinite
  • currentAllowance < value → revert ERC20InsufficientAllowance
  • unchecked { _approve(... currentAllowance - value, false) } — deduct and skip event

The Infinite Allowance Shortcut

_spendAllowance() is called by transferFrom() to verify and deduct the spender's allowance. The key feature is the infinite allowance check: if the current allowance equals type(uint256).max, the allowance is NOT decremented — it stays infinite forever. For normal (finite) allowances, the function checks that the allowance is sufficient, then sets the new allowance to (currentAllowance - value) via _approve() with emitEvent=false.

Why it matters

This function is the reason infinite approvals work in DeFi. When you approve a DEX with type(uint256).max, every subsequent transferFrom skips the allowance deduction entirely — saving gas and avoiding the need to re-approve. The tradeoff is security: if the approved contract is compromised, it can drain your entire balance.

Security Note

The infinite allowance (type(uint256).max) is a double-edged sword. It saves gas and improves UX by eliminating re-approval transactions. But it means a compromised or malicious contract can steal ALL your tokens, not just the approved amount. Security-conscious users should approve exact amounts instead.

Tips & Tricks
  • type(uint256).max = 2^256 - 1 ≈ 1.15 × 10^77 — a number so large it's effectively infinite. No token will ever have this many units in circulation.
  • The unchecked subtraction is safe because the preceding check guarantees currentAllowance >= value.
  • _approve(... false) skips the Approval event — this is the gas optimization discussed in the _approve section.
  • This function is virtual — you can override it to implement custom allowance logic (e.g., time-based expiring allowances).
Common Mistakes
  • Setting exact allowances that are too small for a series of operations — each transferFrom deducts from the allowance, and you'll get ERC20InsufficientAllowance mid-sequence.
  • Not understanding that infinite approvals persist forever — they don't expire and can only be revoked by calling approve(spender, 0).
  • Forgetting to handle the ERC20InsufficientAllowance error in your frontend — show users how much they need to approve.
Key Terms
type(uint256).max
The maximum value of a uint256: 2^256 - 1. Used as a sentinel value meaning "infinite" in the allowance system. When detected, _spendAllowance skips the deduction entirely.
Allowance Deduction
When transferFrom is called with a finite allowance, _spendAllowance reduces the allowance by the transfer amount. This prevents the spender from exceeding the approved total across multiple calls.
💡
Did You Know?

type(uint256).max is 115,792,089,237,316,195,423,570,985,008,687,907,853,269,984,665,640,564,039,457,584,007,913,129,639,935. Even if every atom in the observable universe were a token, you'd still have more than enough. That's why it works as "infinite" — it's physically impossible to exhaust.

ERC20.sol — OpenZeppelin v5.0.0 on OP Sepolia

View on Etherscan