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.
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.
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.
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.
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.
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.
- ▸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.
- ✗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.
- 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).
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.
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
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.
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.
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.
- ▸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.
- ✗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.
- 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.
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.
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
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
Tracks the total number of tokens in existence. Incremented on _mint(), decremented on _burn(). The sum of all _balances entries should always equal _totalSupply.
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.
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.
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.
- ▸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.
- ✗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.
- 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.
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.
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")
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.
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.
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.
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.
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.
- ▸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.
- ✗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.
- 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.
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.
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.
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.
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.
- ▸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.
- ✗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.
- 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.
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.
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.
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.
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.
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.
- ▸_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.
- ✗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.
- _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.
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.
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.
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
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.
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.
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.
- ▸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.
- ✗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.
- 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.
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.
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.
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.
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.
_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.
- ▸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().
- ✗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.
- 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.
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.
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."
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
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)
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.
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.
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.
- ▸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.
- ✗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.
- 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.
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.
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
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().
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.
_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.
- ▸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.
- ✗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.
- _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.
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).
Convenience function that calls the 4-parameter version with emitEvent=true. This is what approve() calls. Not virtual — override the 4-parameter version instead.
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.
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.
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.
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.
- ▸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.
- ✗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.
- 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.
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.
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).
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.
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.
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.
- ▸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).
- ✗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.
- 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.
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