Building a Custom ERC-20 Token: Solidity Implementation


ERC-20 tokens are the backbone of many blockchain applications, from DeFi to gaming and beyond. In this blog, I’ll walk through the process of building a custom ERC-20 token contract in Solidity. I’ll cover topics like what an ERC20 token means, the use cases, decentralized applications that implement erc20, I’ll also cover the key functionalities in the code following the standard of ERC20 contract. Whether you're new to smart contract development or refining your Solidity skills, this guide will provide a clear and practical approach to implementing an ERC-20 token.
Prerequisites
To fully get the best out of this article you should already:
have basic knowledge about web3 and cryptocurrency.
have basic knowledge of solidity syntax, data structure(variables, data location, mapping, functions, etcs).
be ready to learn.
What is an ERC-20 Token
An ERC-20 token is a type of cryptocurrency built on the Ethereum blockchain that follows the ERC-20 standard, as defined in EIP-20.The EIP-20 set the standard implementation of any token under Ethereum blockchain, ensuring tokens work smoothly with Ethereum-based Applications. ERC-20 tokens are called fungible tokens(more on that later)
Use cases of ERC-20 Token
ERC-20 Token usability span across different areas of the Ethereum blockchain. some of these areas include:
Decentralized Applications(uniswap, pancakeswap, orbiter finance, curve finance, sushiswap, etcs).
DeFi(Decentralized Finance): Lending, staking, yield farming, etcs.
DAOs (Decentralized Autonomous Organizations) – Governance tokens for voting on proposals.
Stablecoins – Cryptocurrencies pegged to fiat, like USDT and USDC.
Gaming(Decentraland, The Sandbox) – In-game currencies, assets, and play-to-earn models.
Writing an ERC-20 Token Contract
The ERC20 standard requires that tokens must implement six important functions:
totalSupply: The total supply of ERC-20 tokens
balanceOf: The balance of ERC-20 tokens held by a single wallet address
transfer: Allows one wallet address to send an ERC20 token to another
approve: Gives permission for one address to spend tokens on behalf of another
transferFrom: Allows one address to send tokens from an approved address
allowance: The amount of tokens an approved address can spend on behalf of another
There are also some optional functions that can be included, i defined them in my contract:
name: The name of the ERC-20 token
symbol: The ticker display of the ERC-20 token
decimals: The maximum number of decimal places that a token can be divided
Setting up the solidity contract
The first step is to set up your coding environment. I am using hardhat environment. I believe you are familiar with hardhat already, npx hardhat init
Go to the contract folder created for you with hardhat and create a file named MyErc20.sol
So write your SPDX License, your pragma version, and your contract name.
Now you have set up your solidity contract. Congratulations Nerd 🤓
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
contract ERC20 {
// Congratulations Nerd 🤓
}
Custom errors
Instead of using regular Solidity errors like require()
, we define custom error messages to make transactions cheaper (they save gas). if you are beginner, stick to require()
for now.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
contract ERC20 {
error InvalidAddress();
error Unauthorized();
error balanceIsZero();
error InsufficientBalance();
error InsufficientAllowance();
error InvalidAmount();
}
State Variables
The next step is to define your token name, symbol, decimals, and totalSupply and also the address, there are the state variables. let’s goooo 🚀
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
contract ERC20 {
error InvalidAddress();
error Unauthorized();
error balanceIsZero();
error InsufficientBalance();
error InsufficientAllowance();
error InvalidAmount();
string _name;
string _symbol;
uint8 _decimals = 18; // I'm hyped af
uint256 _totalSupply;
address owner;
}
🚀Next up: Mapping
Mapping keep tracks of values , it is a key-value pair method, whereby a key will lead to a value.
in our case:
we have two mapping variables
balances
→ Keeps track of how many tokens each address has.allowances
→ Allows one address to approve another to spend tokens on their behalf.
Example:
balances[0xABC] = 1000;
→ This means address 0xABC owns 1000 tokens.
mapping(address => uint256) balances;
mapping(address => mapping(address => uint256) ) public allowances;
Events
Solidity events are a way for smart contracts to communicate with each other and with external applications.
In our ERC-20 contract, we implemented three events:
Transfer
→ Triggered when tokens are sent from one address to another.Approval
→ Triggered when someone allows another address to spend their tokens.Minted
→ Triggered when new tokens are created and added to an address.
event Transfer(address indexed _from, address indexed _to, uint256 _value);
event Approval(address indexed _owner, address indexed _spender, uint256 _value);
event Minted(address indexed _to, uint256 _value);
🚀To the big boy: Constructor(Runs once on deployment)
In our contract:
It sets the token name, symbol, and total supply.
_totalSupply = totalSupply_ * 1e18;
This converts the given total supply into Wei (smallest unit of Ethereum).
Example: If you set
totalSupply_ = 1000
, the actual stored supply becomes 1000 * 10¹⁸.
balances[msg.sender] = _totalSupply;
- This assigns all tokens to the deployer's wallet.
constructor(string memory name_, string memory symbol_, uint256 totalSupply_){
_name = name_;
_symbol = symbol_;
_totalSupply = totalSupply_ * 1e18;
balances[msg.sender] = _totalSupply;
owner = msg.sender;
}
Modifier(onlyOwner)
Modifiers are like guards that control who can run certain functions.
onlyOwner
makes sure only the contract owner can execute specific actions (like minting new tokens).If anyone else tries, the function reverts with
Unauthorized()
.
modifier onlyOwner() {
if(msg.sender != owner) revert Unauthorized();
_;
}
Writing our getter functions
Getter functions are functions that just read from state, they don't have any other task than to read from the state variables and they do this by using the view
keyword
We have three getter functions in our contract:
name()
- Get the Token Name.
symbol()
- Get the Symbol name.
decimals()
- Get the decimals places.
totalSupply()
- Get the total supply of the token
function name() public view returns(string memory) {
return _name;
}
function symbol() public view returns(string memory) {
return _symbol;
}
function decimals() public view returns(uint8) {
return _decimals;
}
function totalSupply() public view returns(uint256) {
return _totalSupply;
}
Implementing Core ERC-20 Functions
balanceOf
function.transfer
function.approve
andtransferFrom
functions.
balanceOf
Function
What does it do?
It checks how many tokens an address has.
How does it work?
You call
balanceOf(address _owner)
._owner
is the address you want to check.It returns the token balance of that address.
Example:
If your wallet has 500 tokens, calling balanceOf(your_address)
will return 500.
function balanceOf(address _owner) public view returns(uint256) {
if(_owner == address(0)) revert InvalidAddress();
return balances[_owner];
}
transfer
Function
What does it do?
It moves tokens from your wallet to someone else's.
How does it work?
You call
transfer(address _to, uint256 _value)
._to
is the recipient’s address._value
is the amount of tokens you want to send.Multiplying
_value
by1e18
ensures that the token follows the 18-decimal format.It checks if you have enough balance.
If yes, it reduces your balance and increases the recipient’s balance.
A
Transfer
event is triggered to record the transaction.
Example:
If you have 100 tokens and call transfer(0xABC, 50)
, your balance becomes 50 and 0xABC gets 50 tokens.
function transfer(address _to, uint256 _value) public returns(bool success) {
if(balances[msg.sender] == 0) revert balanceIsZero();
if(balances[msg.sender] < _value) revert InsufficientBalance();
uint256 Amount = _value * 1e18;
balances[msg.sender] -= Amount;
balances[_to] += Amount;
emit Transfer(msg.sender, _to, _value);
success = true;
}
approve
and transferFrom
Functions
These two functions work together to allow someone else (a smart contract or another wallet) to spend your tokens on your behalf.
approve
Function
What does it do?
It lets you give permission to another address to spend a specific amount of your tokens.
How does it work?
You call
approve(address _spender, uint256 _value)
._spender
is the address that gets permission to spend your tokens._value
is the max amount they can spend.The function records this allowance in a mapping.
Example:
You call approve(0xXYZ, 100)
, meaning 0xXYZ is now allowed to spend 100 tokens from your balance.
function approve(address _spender, uint256 _value) public returns(bool) {
if(balances[msg.sender] < _value) revert InsufficientBalance();
uint256 approveAmount = _value * 1e18;
allowances[msg.sender][_spender] = approveAmount;
emit Approval(msg.sender ,_spender, _value);
return true;
}
transferFrom
Function
What does it do?
It allows the approved address to actually transfer tokens on your behalf.
How does it work?
The spender calls
transferFrom(address _from, address _to, uint256 _value)
._from
is your wallet address._to
is the recipient’s address._value
is the amount to be sent.It checks if the spender has approval and enough tokens to send.
If yes, it deducts the tokens from
_from
and sends them to_to
.
Example:
Let’s say you approved 0xXYZ to spend 100 tokens.
Now, 0xXYZ calls transferFrom(your_address, 0xDEF, 50)
.
Your balance decreases by 50.
0xDEF receives 50 tokens.
The remaining allowance is 50 tokens.
function transferFrom(address _from, address _to, uint256 _value) public returns(bool success) {
if(msg.sender == address(0)) revert InvalidAddress();
if(_to == address(0)) revert InvalidAddress();
if(allowances[_from][msg.sender] >= _value){
uint256 transferAmount = _value * 1e18;
balances[_from] -= transferAmount;
allowances[_from][msg.sender] -= transferAmount;
balances[_to] += _value;
emit Transfer(msg.sender, _to, _value);
success = true;
} else {
revert InsufficientAllowance();
}
}
Adding Mint() and Burn() Features
In cryptocurrency, minting means to basically add more of the token to the circulating supply.
Burning means to remove some amount of the tokens from the circulating supply forever!
In this contract we implement a mint()
function
mint()
- Create New Tokens
Only the contract owner can mint new tokens (because of
onlyOwner
).Increases the total supply and adds new tokens to the
_to
address.
function mint(address _to, uint256 _value) public onlyOwner returns(bool success) {
if(_to == address(0)) revert InvalidAddress();
if(_value <= 0) revert InvalidAmount();
uint256 mintAmount = _value * 1e18;
_totalSupply += mintAmount;
balances[_to] += mintAmount;
emit Minted(_to, _value);
success = true;
}
burn()
- Destroy Tokens
This function removes tokens from circulation by sending them to
address(0)
.It checks:
If the caller has enough tokens to burn.
If
_value
is greater than zero.
The total supply decreases because tokens are permanently destroyed.
function burn(uint256 _value) public returns(bool success) {
if(msg.sender == address(0)) revert InvalidAddress();
if(_value <= 0) revert InvalidAmount();
if(balances[msg.sender] < _value) revert InsufficientBalance();
uint256 burnAmount = _value * 1e18;
_totalSupply -= burnAmount;
balances[msg.sender] -= burnAmount;
balances[address(0)] += burnAmount;
emit Transfer(msg.sender, address(0), _value);
success = true;
}
🍾brooooo, it is a lot!, finally we did it, we wrote our erc20 token contract from scratch, I am proud of you!, Tell your mummy and daddy that you are now a smart contract developer.
So putting everything together from writing the License to the burn function.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
contract ERC20 {
error InvalidAddress();
error Unauthorized();
error balanceIsZero();
error InsufficientBalance();
error InsufficientAllowance();
error InvalidAmount();
string _name;
string _symbol;
uint8 _decimals = 18;
uint256 _totalSupply;
address owner;
mapping(address => uint256) balances;
mapping(address => mapping(address => uint256) ) public allowances;
event Transfer(address indexed _from, address indexed _to, uint256 _value);
event Approval(address indexed _owner,address indexed _spender, uint256 _value);
event Minted(address indexed _to, uint256 _value);
constructor(string memory name_, string memory symbol_, uint256 totalSupply_){
_name = name_;
_symbol = symbol_;
_totalSupply = totalSupply_ * 1e18;
balances[msg.sender] = _totalSupply;
owner = msg.sender;
}
modifier onlyOwner() {
if(msg.sender != owner) revert Unauthorized();
_;
}
function name() public view returns(string memory) {
return _name;
}
function symbol() public view returns(string memory) {
return _symbol;
}
function decimals() public view returns(uint8) {
return _decimals;
}
function totalSupply() public view returns(uint256) {
return _totalSupply;
}
function balanceOf(address _owner) public view returns(uint256) {
if(_owner == address(0)) revert InvalidAddress();
return balances[_owner];
}
function transfer(address _to, uint256 _value) public returns(bool success) {
if(balances[msg.sender] == 0) revert balanceIsZero();
if(balances[msg.sender] < _value) revert InsufficientBalance();
uint256 Amount = _value * 1e18;
balances[msg.sender] -= Amount;
balances[_to] += Amount;
emit Transfer(msg.sender, _to, _value);
success = true;
}
function transferFrom(address _from, address _to, uint256 _value) public returns(bool success) {
if(msg.sender == address(0)) revert InvalidAddress();
if(_to == address(0)) revert InvalidAddress();
if(allowances[_from][msg.sender] >= _value){
uint256 transferAmount = _value * 1e18;
balances[_from] -= transferAmount;
allowances[_from][msg.sender] -= transferAmount;
balances[_to] += _value;
emit Transfer(msg.sender, _to, _value);
success = true;
}else {
revert InsufficientAllowance();
}
}
function approve(address _spender, uint256 _value) public returns(bool) {
if(balances[msg.sender] < _value) revert InsufficientBalance();
uint256 approveAmount = _value * 1e18;
allowances[msg.sender][_spender] = approveAmount;
emit Approval(msg.sender ,_spender, _value);
return true;
}
function mint(address _to, uint256 _value) public onlyOwner returns(bool success) {
if(_to == address(0)) revert InvalidAddress();
if(_value <= 0) revert InvalidAmount();
uint256 mintAmount = _value * 1e18;
_totalSupply += mintAmount;
balances[_to] += mintAmount;
emit Minted(_to, _value);
success = true;
}
function burn(uint256 _value) public returns(bool success) {
if(msg.sender == address(0)) revert InvalidAddress();
if(_value <= 0) revert InvalidAmount();
if(balances[msg.sender] < _value) revert InsufficientBalance();
uint256 burnAmount = _value * 1e18;
_totalSupply -= burnAmount;
balances[msg.sender] -= burnAmount;
balances[address(0)] += burnAmount;
emit Transfer(msg.sender, address(0), _value);
success = true;
}
}
So, that’s the breakdown of your ERC-20 token contract! 🎉
You can send tokens like digital cash. 💸
You can approve others to spend on your behalf (just don’t trust them too much 😉).
You can mint more tokens if you’re the boss. 👑
You can burn tokens if you feel generous or regret your life choices. 🔥
At the end of the day, it’s just a fancy way to manage numbers on the blockchain. But hey, now you know how the magic works! 🚀
Need anything else? Let’s keep the Solidity party going! 🏗️
Leaving you at this point, go apply to openzeppelin and show them my article, the job is yours!
Subscribe to my newsletter
Read articles from Victor Adeoba directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
