ERC-20 Solidity Code Explained: A Line-by-Line Breakdown
Every ERC-20 token on Ethereum — from USDC to Uniswap's UNI — is built on the same foundational Solidity code. Understanding what that code does line by line gives you real clarity about what you're deploying, why security choices matter, and how your token interacts with wallets and DeFi protocols. This guide walks through a complete, production-ready ERC-20 contract in plain English, no prior Solidity experience required.
Why Understanding the Code Matters Even If You Don't Write It
Most people creating an ERC-20 token today use a tool rather than writing raw Solidity from scratch. That's a smart choice — hand-rolled contracts introduce security risks that audited libraries eliminate. But understanding what's happening inside the contract you're deploying matters for several reasons.
- Audit conversations: If you ever hire a security auditor, you need to understand what they're reviewing and why their findings matter.
- Integration decisions: DeFi protocols, exchanges, and wallet developers will ask questions about your token's behavior. Knowing your
approveandtransferFromflow lets you answer them. - Trust signals: Investors and early adopters who read smart contracts will appreciate a founder who understands their own token.
- Bug identification: You can't spot something wrong in a contract review if you don't know what "right" looks like.
If you're new to creating ERC-20 tokens, check out our guide on how to create an ERC-20 token for a broader overview before diving into the code. And if you'd rather skip straight to deployment, you can always create an ERC-20 token without writing Solidity using our no-code tool. But for those who want the full picture, let's get into the code.
The Full ERC-20 Contract: What You're Working With
Before breaking it apart, here's a complete, minimal ERC-20 token contract using OpenZeppelin. This is essentially what gets deployed when you create erc20 token with a modern tool:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor(
string memory name_,
string memory symbol_,
uint256 initialSupply_
) ERC20(name_, symbol_) {
_mint(msg.sender, initialSupply_ * 10 ** decimals());
}
}
That's it. Thirteen lines of Solidity. The brevity is deceptive — there are hundreds of lines of carefully audited code loaded in from that single import. Let's unpack every piece.
Line 1: SPDX License Identifier
// SPDX-License-Identifier: MIT
This is a comment, so Solidity itself ignores it — but the toolchain doesn't. SPDX stands for Software Package Data Exchange, a standardized way of declaring software licenses. The Solidity compiler will emit a warning if this line is missing, and source verification tools like Etherscan use it to tag your contract's license automatically.
MIT is the most permissive open-source license. It means anyone can read, fork, and build on your token's code. For most token projects this is exactly what you want — transparency and community trust. If you had proprietary logic, you might use UNLICENSED to signal that the source is viewable but not freely forkable.
Common choices you'll see in the wild:
MIT— permissive, attribution onlyApache-2.0— permissive with patent rightsGPL-3.0— copyleft, derivatives must also be openUNLICENSED— proprietary, not for redistribution
Pragma Directive: Pinning the Solidity Version
pragma solidity ^0.8.20;
The pragma line tells the Solidity compiler which version of the language your code is written for. The caret (^) symbol means "this version or any compatible minor update" — so ^0.8.20 will compile with 0.8.20, 0.8.21, 0.8.22, and so on, but not with 0.9.x.
Version 0.8.x is the critical threshold. Starting with 0.8.0, Solidity introduced built-in overflow and underflow protection. Before that, contracts needed the SafeMath library to prevent arithmetic bugs. Today, you get that protection automatically — uint256 operations revert if they would overflow rather than wrapping silently.
pragma solidity 0.8.20; without the caret. This can cause problems if a deployment tool or framework uses a slightly different patch version. The caret range is the safe middle ground for most token contracts.
When you create erc20 token with modern tools, the pragma is handled automatically with a safe, audited version selection. But if you're reviewing a contract someone else wrote, check that the version is 0.8.x or higher — any contract still on 0.6.x or 0.7.x without SafeMath is a red flag.
The Import Statement: OpenZeppelin as Your Foundation
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
This single line pulls in OpenZeppelin's battle-tested ERC-20 implementation. OpenZeppelin is the de facto standard library for Ethereum smart contracts — their code has been audited dozens of times, reviewed by thousands of developers, and is used by hundreds of billions of dollars worth of token contracts.
The imported file itself defines the full IERC20 interface implementation, plus useful metadata functions like name(), symbol(), and decimals(). It handles all the internal bookkeeping: balance mappings, allowance mappings, and event emissions.
For a deeper look at what OpenZeppelin provides and why it matters for security, see our guide to the OpenZeppelin ERC-20 implementation. The short version: don't roll your own ERC-20 math. The library has solved these problems correctly; your job is to configure it for your token's specifics.
The Contract Declaration
contract MyToken is ERC20 {
This line does two things simultaneously. First, it declares a new smart contract named MyToken. Second, the is ERC20 clause establishes inheritance — your contract inherits all the functions, state variables, and logic from OpenZeppelin's ERC20 base contract.
Solidity inheritance works similarly to object-oriented languages like Java or Python. Your MyToken contract gets everything ERC20 defines, and you can override specific functions if you need custom behavior. You don't have to override anything for a basic token — the inherited functions are sufficient.
The name MyToken in the contract declaration is separate from the token's display name and symbol. It's the Solidity identifier used internally and shown on block explorers. When you ERC-20 token creator tools compile your contract, this contract name becomes part of the ABI and Etherscan display.
State Variables: Where the Token Data Lives
State variables in Solidity are stored permanently on the blockchain. They're the single source of truth for your token's data. In the OpenZeppelin ERC20 base contract (which your contract inherits), the key state variables are:
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
Let's walk through each one:
_balances: A mapping from wallet address to token balance. Every address in existence either has an entry here or is assumed to have a balance of zero. When you transfer tokens, this mapping gets updated._allowances: A nested mapping tracking how many tokens address A has approved address B to spend on its behalf. This is the backbone of DeFi composability — it's how a DEX can move your tokens when you swap._totalSupply: The total number of tokens in circulation, tracked as a single integer. This gets updated when tokens are minted or burned._nameand_symbol: Stored strings for your token's display name and ticker. These are what MetaMask and Etherscan show users.
All of these are marked private, meaning external contracts can't read them directly. Instead, public getter functions (balanceOf, allowance, totalSupply, name, symbol) expose this data — and those functions are part of the ERC-20 standard interface every token must implement.
The Constructor: Setting Name, Symbol, and Initial Supply
constructor(
string memory name_,
string memory symbol_,
uint256 initialSupply_
) ERC20(name_, symbol_) {
_mint(msg.sender, initialSupply_ * 10 ** decimals());
}
The constructor runs exactly once — at the moment the contract is deployed. It never runs again. This is where you configure the immutable properties of your token.
Breaking it apart:
- Parameters:
name_,symbol_, andinitialSupply_are passed in at deploy time. The trailing underscores are a Solidity convention to avoid naming conflicts with state variables. ERC20(name_, symbol_): This calls the parent contract's constructor, storing your token's name and symbol in the inherited_nameand_symbolstate variables._mint(msg.sender, ...): The internal_mintfunction creates new tokens out of thin air and assigns them tomsg.sender— the wallet address that deployed the contract. This is how the initial supply gets created.initialSupply_ * 10 ** decimals(): This handles the decimal math. ERC-20 tokens store balances as integers — there are no decimal points on-chain. The standarddecimals()function returns 18, so multiplying by10 ** 18converts a human-readable supply (e.g., 1,000,000) into its internal representation (1,000,000,000,000,000,000,000,000).
When creating erc20 token via a deployment interface, you supply these three values in a form field — the tool constructs and deploys this contract with your inputs. The result is identical to writing and deploying the Solidity yourself.
The Six Mandatory Functions — Deep Dive
The ERC-20 standard (formally EIP-20) mandates six functions that every compliant token must implement. OpenZeppelin's ERC20.sol implements all of them, so when you inherit from it, your token is automatically compliant.
1. totalSupply()
function totalSupply() public view returns (uint256);
Returns the total number of tokens in existence. Simple, but critical — DEXes, analytics tools, and wallets all query this to display market cap data and circulating supply.
2. balanceOf(address account)
function balanceOf(address account) public view returns (uint256);
Returns the token balance of a specific address. This is what MetaMask calls to show you your balance. The view modifier means it's read-only — calling it costs no gas (unless called from within a transaction).
3. transfer(address to, uint256 amount)
function transfer(address to, uint256 amount) public returns (bool);
Moves amount tokens from the caller's account to to. Returns true on success, reverts on failure. This is the most commonly called function in any token's lifetime.
4. allowance(address owner, address spender)
function allowance(address owner, address spender) public view returns (uint256);
Returns how many tokens spender is allowed to move on behalf of owner. This is a read-only function that DEXes query before attempting a transferFrom.
5. approve(address spender, uint256 amount)
function approve(address spender, uint256 amount) public returns (bool);
Grants spender permission to move up to amount tokens from your account. This is what you're doing when you "approve" a DEX in your wallet — you're calling this function.
6. transferFrom(address from, address to, uint256 amount)
function transferFrom(address from, address to, uint256 amount) public returns (bool);
Moves tokens on behalf of another address, consuming from their approved allowance. DeFi protocols use this to execute swaps, deposits, and other operations after you've granted approval.
Transfer Logic: How Tokens Move
Inside OpenZeppelin's implementation, both transfer and transferFrom ultimately call the same internal function: _transfer. Here's a simplified version of what it does:
function _transfer(
address from,
address to,
uint256 amount
) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
unchecked {
_balances[from] = fromBalance - amount;
_balances[to] += amount;
}
emit Transfer(from, to, amount);
}
Step by step:
- Zero address checks: Sending to or from
address(0)is used as a burn/mint sentinel — the function explicitly prevents accidental burns via regular transfers. - Balance check: The sender must have enough tokens. If not, the transaction reverts and no state changes occur.
- Balance update: The sender's balance decreases; the recipient's increases. The
uncheckedblock skips overflow protection for efficiency — we already verified the subtraction is safe with the balance check above. - Event emission: Every transfer emits a
Transferevent so blockchain explorers and off-chain listeners can track token movements without reading state directly.
For transferFrom, there's one additional step: the allowance gets reduced by the transferred amount before the balance update happens.
Approval Mechanism: How DeFi Protocols Access Your Tokens
The approve / transferFrom pattern is ERC-20's mechanism for DeFi composability. Here's the flow:
- You call
approve(uniswap_router, 1000 * 10**18)— giving Uniswap permission to move up to 1,000 of your tokens. - When you initiate a swap, Uniswap's contract calls
transferFrom(your_address, pool_address, amount)to move the tokens. - The allowance decreases by
amountafter eachtransferFrom.
function approve(address spender, uint256 amount) public virtual returns (bool) {
address owner = msg.sender;
_approve(owner, spender, amount);
return true;
}
function _approve(address owner, address spender, uint256 amount) internal virtual {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
_allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
increaseAllowance / decreaseAllowance helper functions, or the newer EIP-2612 permit extension. For more on protecting your token, see our article on smart contract security best practices.
When you erc20 create token using OpenZeppelin, you automatically get the safer increaseAllowance and decreaseAllowance functions in addition to the standard approve. These let spenders adjust approvals atomically, eliminating the race condition.
Events: The Blockchain's Audit Trail
ERC-20 defines two events that must be emitted on certain actions:
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
Events in Solidity are not stored in contract state — they're written to transaction logs, which are cheaper than storage but still permanently recorded on-chain. The indexed keyword on certain parameters makes them searchable: you can query "all Transfer events where from equals this address" efficiently.
Why do events matter for token creators?
- Block explorers: Etherscan reads Transfer events to display your token's transaction history. Without properly emitted events, transfers won't show up.
- Wallet support: MetaMask and other wallets discover token balances by watching Transfer events. Your token won't appear automatically without them.
- Analytics and indexing: The Graph protocol and similar tools index events to power DeFi dashboards and analytics.
- Compliance tooling: KYC/AML tools trace token flows using Transfer events.
Every time _transfer runs, it emits Transfer. Every time _approve runs, it emits Approval. Minting emits Transfer(address(0), recipient, amount), and burning emits Transfer(sender, address(0), amount) — this convention lets explorers display mint and burn events in the same transaction history view.
Optional Extensions and What They Add
A basic ERC-20 is feature-complete, but real-world tokens often need additional capabilities. OpenZeppelin packages these as modular extensions you import and inherit alongside the base ERC20.sol:
ERC20Burnable
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
Adds burn(amount) and burnFrom(account, amount) functions, letting holders permanently destroy their tokens. Useful for deflationary mechanics and supply management.
ERC20Mintable (via AccessControl or Ownable)
import "@openzeppelin/contracts/access/Ownable.sol";
OpenZeppelin doesn't have a standalone "Mintable" extension — instead, you combine access control with a custom mint function. The Ownable pattern is simplest: only the contract owner can mint new tokens. AccessControl gives you role-based permissions for more complex setups.
ERC20Pausable
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
Lets an authorized address pause all token transfers in an emergency. Useful for detecting and responding to exploits, but introduces centralization that sophisticated investors may scrutinize.
ERC20Permit (EIP-2612)
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
Adds a permit function enabling gasless approvals via signed messages. Instead of paying gas to call approve, users sign a message off-chain that a contract can use to set allowances. This powers "approve and swap in one transaction" UX.
ERC20Capped
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol";
Enforces a hard cap on total supply. No minting can exceed the cap, guaranteed at the contract level — useful for tokens that want to credibly commit to a maximum supply.
ERC20Votes
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
Adds delegation and voting weight tracking, enabling governance use cases. This is what DAO governance tokens like UNI and COMP use. Holders can delegate their voting power to other addresses without transferring tokens.
ERC20Votes, ERC20Permit, and ERC20Burnable. Each adds a specific capability without interfering with the others, as long as you handle constructor chains correctly.
What You Don't Need to Write
Here's what's remarkable about modern token creation: everything described in this article is already written, audited, and ready to use. When you deploy your token with a tool like ours, you get all of the above — correct SPDX headers, safe pragma versions, full OpenZeppelin inheritance, proper constructor logic, all six mandatory functions, event emission, and your choice of extensions — without writing a single line of Solidity.
That's not a shortcut. It's using the right tool for the job. Audited, production-grade code doesn't become less reliable because a tool deployed it rather than a developer typing manually. In fact, the opposite is often true — human-typed contracts introduce typos, logic errors, and subtle bugs that the OpenZeppelin library has already solved.
What a no-code tool handles for you:
- Selecting the correct compiler version and optimization settings
- Choosing the right OpenZeppelin version for your Solidity version
- Encoding constructor arguments correctly for deployment
- Submitting the deployment transaction with appropriate gas
- Preparing contract source and ABI for verification
After creating erc20 token, the next step most projects tackle is verification. Making your source code readable on Etherscan is a critical trust signal — see our guide to verify your contract on Etherscan for the full walkthrough.
If you're ready to launch, our ERC-20 token creator handles everything from the code generation to the deployment transaction. You configure; it deploys.
Frequently Asked Questions
Do I need to understand Solidity to create an ERC-20 token?
No. You don't need to write or fully understand Solidity to deploy a token — that's what deployment tools are for. But having a working understanding of what your contract does helps you make better decisions about features, extensions, and security. This guide is aimed at giving you exactly that understanding without requiring you to become a full Solidity developer.
What makes an ERC-20 token different from a custom token standard?
ERC-20 is a standardized interface — any contract that implements the six mandatory functions and two events is an ERC-20 token. This standardization is what makes your token immediately compatible with every DEX, wallet, and protocol in the Ethereum ecosystem. A custom token standard would require every platform to write special support code for your token before it could be listed or traded.
What is the decimals() function and can I change it?
The decimals() function returns the number of decimal places your token uses for display purposes. The default is 18, matching Ether. On-chain, everything is stored as integers — decimals() just tells wallets and explorers how to format the display. You can override it to return a different value (like 6 for USDC-like tokens), but you should do this at deployment time. Most tokens use 18 unless they have a specific reason not to.
Is it safe to use OpenZeppelin contracts for my token?
Yes — OpenZeppelin is the industry standard precisely because its contracts have been extensively audited and battle-tested across thousands of live deployments. Using OpenZeppelin for creating erc20 token contracts is considered a best practice, not a shortcut. The alternative — writing your own ERC-20 math from scratch — introduces risks that experienced Solidity developers routinely advise against. For more context, see our guide on smart contract security best practices.
Can I add features after my token is deployed?
Standard ERC-20 contracts are not upgradeable — once deployed, the code is fixed. This is actually a feature for most tokens: it means no one (including you) can secretly change the rules after investors have committed funds. If you need upgradeability, you'd implement a proxy pattern like OpenZeppelin's TransparentUpgradeableProxy, but this adds complexity and introduces centralization risks that you'd need to disclose to your community.