In our previous post, we explored the crucial role of blockchain standards in enabling interoperability and composability within the decentralized ecosystem.
We learned that standards like ERC-20 provide a common language for smart contracts and applications to interact seamlessly.
Today, we're putting that understanding into practice by diving deep into the most widely adopted token standard on Ethereum: ERC-20 tokens.
If you've ever held cryptocurrencies like USDC, DAI, UNI, or even wrapped Bitcoin (WBTC) on Ethereum, you've interacted with ERC-20 tokens.
What Are ERC-20 Tokens? The Foundation of Digital Assets
An ERC-20 token is a standard for fungible (interchangeable) tokens on the Ethereum blockchain. "Fungible" means each unit of the token is identical to any other unit,
Just like one dollar note is interchangeable with another, or one Bitcoin is interchangeable with another Bitcoin.
This standard defines a common set of rules and functions that any compliant token contract must implement.
This standardization enables:
- Universal wallet support for displaying any ERC-20 token balance
- Seamless Decentralized Exchanges (DEXs) trading between different ERC-20 tokens
- DeFi protocol integration accepting various tokens as collateral
- Cross-platform compatibility across thousands of applications
Key ERC-20 Functions We'll Explore
The ERC-20 standard specifies several core functions.
We'll focus on the most commonly used ones for reading balances and making transfers, including the critical approve
and transferFrom
pattern used by dApps:
Read Functions (View State)
1.**totalSupply()**
- Returns the total number of tokens in existence.
2. **balanceOf(address)**
- Returns the token balance of a specific address.
3.**allowance(owner, spender)**
- Returns the amount of tokens that an owner
has allowed a spender
to withdraw.
Write Functions (Change State)
-
**transfer(recipient, amount)**
- Transfers tokens directly from the message sender (the account initiating the transaction) to a recipient.
2. **approve(spender, amount)**
- Allows a "spender" (another contract or address, often a dApp) to withdraw a specified amount
of tokens from the sender's account.
3. **transferFrom(sender, recipient, amount)**
- Transfers tokens from one address (sender
) to another (recipient
) on behalf of the sender
.
This function can only be called by a spender
who has received prior approval
from the sender
.
Building Your Own ERC-20 Token
To practice interacting with an ERC-20 token, we'll deploy a simple one to our local Ganache blockchain using Remix,
just like we did with our Counter
contract. This will give you full control over your own test tokens.
Here's our production-ready SimpleERC20
contract:
// SimpleERC20.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleERC20 {
// Token metadata
string public name = "My Test Token";
string public symbol = "MTT";
uint8 public decimals = 18; // Standard precision (like ETH)
uint256 public totalSupply;
// Core mappings for token accounting
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
// Events for blockchain logging and dApp integration
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
// Constructor: Initialize token supply
constructor(uint256 initialSupply) {
totalSupply = initialSupply * 10**decimals; // Convert to wei-like units
balanceOf[msg.sender] = totalSupply; // Assign all tokens to deployer
emit Transfer(address(0), msg.sender, totalSupply);
}
// Direct token transfer
function transfer(address _to, uint256 _value) public returns (bool) {
require(_to != address(0), "SimpleERC20: Transfer to zero address");
require(balanceOf[msg.sender] >= _value, "SimpleERC20: Insufficient balance");
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
}
// Approve spender allowance
function approve(address _spender, uint256 _value) public returns (bool) {
require(_spender != address(0), "SimpleERC20: Approve to zero address");
allowance[msg.sender][_spender] = _value;
emit Approval(msg.sender, _spender, _value);
return true;
}
// Third-party transfer (requires prior approval)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool) {
require(_from != address(0), "SimpleERC20: Transfer from zero address");
require(_to != address(0), "SimpleERC20: Transfer to zero address");
require(balanceOf[_from] >= _value, "SimpleERC20: Insufficient balance");
require(allowance[_from][msg.sender] >= _value, "SimpleERC20: Allowance exceeded");
balanceOf[_from] -= _value;
balanceOf[_to] += _value;
allowance[_from][msg.sender] -= _value;
emit Transfer(_from, _to, _value);
return true;
}
}
Deployment Steps (using Remix IDE to Ganache):
Follow the detailed steps from Part 2: Deploying Your First Smart Contract for deploying a contract to Ganache, but with these specifics:
- Paste the
**SimpleERC20.sol**
code into a new file in Remix (e.g.,SimpleERC20.sol
).
2. Compile it (ensure EVM version 'Paris').
3. Go to the "Deploy & Run Transactions" tab.
4. Ensure "DEV - GANACHE PROVIDER" is selected and connected.
- Before clicking "Deploy":
- Next to the "Deploy" button, you'll see a field for the
constructor
arguments. OurSimpleERC20
constructor takesinitialSupply
as auint256
. - Enter a large number here for the initial supply, e.g.,
1000000000000000000000000
(which is1,000,000
tokens with 18 decimals, or 106 * 10^18 Wei-like units).
6. Click the orange "Deploy" button and confirm the MetaMask transaction.
7. Crucially, copy the NEW **CONTRACT_ADDRESS**
and the updated **CONTRACT_ABI**
from the deployed contract section.
Interacting with Your ERC-20 Token using web3.py
Now, let's modify our app.py
script to interact with your newly deployed SimpleERC20
token.
import os
import json
from web3 import Web3, HTTPProvider
from web3.middleware import ExtraDataToPOAMiddleware
from eth_account import Account
from dotenv import load_dotenv
load_dotenv()
RPC_URL = os.getenv("RPC_URL")
PRIVATE_KEY = os.getenv("GANACHE_PRIVATE_KEY")
if not RPC_URL or not PRIVATE_KEY:
raise ValueError("RPC_URL or GANACHE_PRIVATE_KEY not found in .env file.")
try:
w3 = Web3(HTTPProvider(RPC_URL))
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
if not w3.is_connected():
print(f"❌ Failed to connect to Ethereum node at {RPC_URL}")
exit()
print(f"✅ Successfully connected to Ethereum node at {RPC_URL}")
sender_account = Account.from_key(PRIVATE_KEY)
print(f"\n🔑 Sender Account Address: {sender_account.address}")
# Note: This is your ETH balance, not token balance. You need ETH for gas!
print(f"💰 Sender ETH Balance: {w3.from_wei(w3.eth.get_balance(sender_account.address), 'ether')} ETH")
# --- ERC-20 Token Interaction ---
print("\n--- ERC-20 Token Interaction ---")
# IMPORTANT: Replace with your NEWLY deployed SimpleERC20 contract address and ABI
# Get these values after deploying SimpleERC20.sol in Remix
ERC20_CONTRACT_ADDRESS = "0x6213A6c50505ebCA6391d002876Af1c70168830a" # e.g., "0x5FbDB2315678afecb367f032d93F642f64180aa3"
ERC20_CONTRACT_ABI = json.loads('''
[
{
"constant": true,
"inputs": [],
"name": "name",
"outputs": [{"name": "", "type": "string"}],
"payable": false, "stateMutability": "view", "type": "function"
},
{
"constant": false,
"inputs": [{"name": "_spender", "type": "address"}, {"name": "_value", "type": "uint256"}],
"name": "approve",
"outputs": [{"name": "", "type": "bool"}],
"payable": false, "stateMutability": "nonpayable", "type": "function"
},
{
"constant": true,
"inputs": [],
"name": "totalSupply",
"outputs": [{"name": "", "type": "uint256"}],
"payable": false, "stateMutability": "view", "type": "function"
},
{
"constant": false,
"inputs": [{"name": "_from", "type": "address"}, {"name": "_to", "type": "address"}, {"name": "_value", "type": "uint256"}],
"name": "transferFrom",
"outputs": [{"name": "", "type": "bool"}],
"payable": false, "stateMutability": "nonpayable", "type": "function"
},
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [{"name": "", "type": "uint8"}],
"payable": false, "stateMutability": "view", "type": "function"
},
{
"constant": true,
"inputs": [{"name": "_owner", "type": "address"}],
"name": "balanceOf",
"outputs": [{"name": "balance", "type": "uint256"}],
"payable": false, "stateMutability": "view", "type": "function"
},
{
"constant": true,
"inputs": [],
"name": "symbol",
"outputs": [{"name": "", "type": "string"}],
"payable": false, "stateMutability": "view", "type": "function"
},
{
"constant": false,
"inputs": [{"name": "_to", "type": "address"}, {"name": "_value", "type": "uint256"}],
"name": "transfer",
"outputs": [{"name": "", "type": "bool"}],
"payable": false, "stateMutability": "nonpayable", "type": "function"
},
{
"constant": true,
"inputs": [{"name": "_owner", "type": "address"}, {"name": "_spender", "type": "address"}],
"name": "allowance",
"outputs": [{"name": "", "type": "uint256"}],
"payable": false, "stateMutability": "view", "type": "function"
},
{
"inputs": [{"internalType": "uint256", "name": "initialSupply", "type": "uint256"}],
"payable": false, "stateMutability": "nonpayable", "type": "constructor"
},
{
"anonymous": false,
"inputs": [{"indexed": true, "name": "from", "type": "address"}, {"indexed": true, "name": "to", "type": "address"}, {"indexed": false, "name": "value", "type": "uint256"}],
"name": "Transfer", "type": "event"
},
{
"anonymous": false,
"inputs": [{"indexed": true, "name": "owner", "type": "address"}, {"indexed": true, "name": "spender", "type": "address"}, {"indexed": false, "name": "value", "type": "uint256"}],
"name": "Approval", "type": "event"
}
]
''') # IMPORTANT: Replace this entire multi-line string with your copied, UPDATED ABI
# Create the ERC-20 contract instance
token_contract = w3.eth.contract(address=ERC20_CONTRACT_ADDRESS, abi=ERC20_CONTRACT_ABI)
print(f"✅ ERC-20 Token contract loaded successfully at address: {ERC20_CONTRACT_ADDRESS}")
# -- 1. Read Token Information ---
token_name = token_contract.functions.name().call()
token_symbol = token_contract.functions.symbol().call()
token_decimals = token_contract.functions.decimals().call()
token_total_supply_raw = token_contract.functions.totalSupply().call()
# Convert total supply to human-readable format
# Note: Tokens have 'decimals', similar to how ETH has 18 decimal places (Wei)
token_total_supply = token_total_supply_raw / (10**token_decimals)
print(f"\nℹ️ Token Name: {token_name} ({token_symbol})")
print(f"ℹ️ Token Decimals: {token_decimals}")
print(f"ℹ️ Total Supply (Raw): {token_total_supply_raw}")
print(f"ℹ️ Total Supply ({token_symbol}): {token_total_supply}")
# Get balance of the sender (deployer) account
sender_token_balance_raw = token_contract.functions.balanceOf(sender_account.address).call()
sender_token_balance = sender_token_balance_raw / (10**token_decimals)
print(f"💰 {token_symbol} Balance of {sender_account.address}: {sender_token_balance} {token_symbol}")
# --- 2. Transfer Tokens (using `transfer` function) ---
# Get a recipient address from Ganache (e.g., the second account)
if "127.0.0.1" in RPC_URL.lower() and len(w3.eth.accounts) > 1:
recipient_address = w3.eth.accounts[1]
else:
# Fallback if not Ganache or not enough accounts
# Use a random address for demonstration if Ganache accounts are not available
recipient_address = "0x789F8a60F25e3d7F6A8B08A4E9A4B8C1D6E5F4A3"
transfer_amount_human = 10 # Transfer 10 tokens
# Convert human-readable amount to raw token units (accounting for decimals)
transfer_amount_raw = int(transfer_amount_human * (10**token_decimals))
print(f"\n➡️ Attempting to transfer {transfer_amount_human} {token_symbol} to {recipient_address}...")
# Get the current nonce for the sender. Nonce prevents replay attacks.
nonce = w3.eth.get_transaction_count(sender_account.address)
# Build the 'transfer' transaction. This creates the transaction object.
transfer_txn = token_contract.functions.transfer(
recipient_address,
transfer_amount_raw
).build_transaction({
'from': sender_account.address,
'nonce': nonce,
'gas': 200000, # Set a reasonable gas limit for the transaction
'gasPrice': w3.eth.gas_price # Get current gas price from the network
})
# Sign the transaction with the sender's private key
signed_txn = w3.eth.account.sign_transaction(transfer_txn, private_key=PRIVATE_KEY)
# Send the signed raw transaction to the network
tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
print(f"🚀 Transfer transaction sent! Hash: {tx_hash.hex()}")
# Wait for the transaction to be mined and get the receipt
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
if tx_receipt['status'] == 1:
print("🎉 Transfer successful!")
# Verify new balances after transfer
new_sender_balance_raw = token_contract.functions.balanceOf(sender_account.address).call()
new_recipient_balance_raw = token_contract.functions.balanceOf(recipient_address).call()
print(f"💰 New {token_symbol} Balance of {sender_account.address}: {new_sender_balance_raw / (10**token_decimals)} {token_symbol}")
print(f"💰 New {token_symbol} Balance of {recipient_address}: {new_recipient_balance_raw / (10**token_decimals)} {token_symbol}")
else:
print("❌ Transfer failed! Transaction receipt:")
print(tx_receipt)
# --- 3. Approve and TransferFrom (for dApp interaction) ---
# This pattern is used when a dApp needs to move tokens on your behalf (e.g., in a DEX, you 'approve' the DEX contract to spend your tokens, then the DEX calls transferFrom).
# Let's use the third Ganache account as our 'spender' for demonstration.
if "127.0.0.1" in RPC_URL.lower() and len(w3.eth.accounts) > 2:
spender_address = w3.eth.accounts[2]
else:
# Another random address if Ganache accounts are not available
spender_address = "0x89C6F7D8A7F4B2C6F9C7D6E5F4A3B8C1D6E5F4A3"
approve_amount_human = 5 # Allow the spender to move 5 tokens
approve_amount_raw = int(approve_amount_human * (10**token_decimals))
print(f"\n➡️ Approving {spender_address} to spend {approve_amount_human} {token_symbol} from {sender_account.address}...")
# Get nonce for the approval transaction
nonce = w3.eth.get_transaction_count(sender_account.address)
# Build the 'approve' transaction
approve_txn = token_contract.functions.approve(
spender_address,
approve_amount_raw
).build_transaction({
'from': sender_account.address,
'nonce': nonce,
'gas': 100000, # Gas for approve is typically less than transfer
'gasPrice': w3.eth.gas_price
})
# Sign and send the approve transaction
signed_approve_txn = w3.eth.account.sign_transaction(approve_txn, private_key=PRIVATE_KEY)
approve_tx_hash = w3.eth.send_raw_transaction(signed_approve_txn.raw_transaction)
print(f"🚀 Approve transaction sent! Hash: {approve_tx_hash.hex()}")
# Wait for approve transaction to be mined
print("⏳ Waiting for approve transaction to be mined...")
approve_tx_receipt = w3.eth.wait_for_transaction_receipt(approve_tx_hash)
if approve_tx_receipt['status'] == 1:
print("🎉 Approve successful!")
# Verify allowance
current_allowance_raw = token_contract.functions.allowance(sender_account.address, spender_address).call()
print(f"💰 Current Allowance for {spender_address} on {sender_account.address}: {current_allowance_raw / (10**token_decimals)} {token_symbol}")
else:
print("❌ Approve failed! Transaction receipt:")
print(approve_tx_receipt)
except Exception as e:
print(f"An error occurred: {e}")
print("\nEnsure your ERC20_CONTRACT_ADDRESS and ERC20_CONTRACT_ABI are correct and match your newly deployed token.")
print("Also, ensure your Ganache is running with the correct RPC URL and private key, and that the sender account has enough ETH for gas.")
You'll see output detailing:
- The token's basic information (name, symbol, decimals, total supply).
2. Your initial token balance (the deployer account will have the entire initial supply).
3. A transfer
transaction, showing tokens moving from your account to a recipient account on Ganache.
4. An approve
transaction, granting a "spender" address permission to move a certain amount of your tokens.
5. Verification of the allowance
set.
You'd see something like this
Here is an integration workflow to give you a clear picture of how the ERC-20 standard powers foundational DeFi use cases
For DEX Integration Flow (e.g. Uniswap):
- User Calls
approve(dex_address, amount)
- The user allows the DEX smart contract to withdraw a set amount of their ERC-20 tokens.
- This is done via the
approve
function of the token contract.
2. DEX Calls transferFrom(user, dex_pool, amount)
- The DEX uses
transferFrom
to move tokens from the user's wallet into the liquidity pool. - This only works because the user previously approved the DEX to do this.
3. Trade Executes Atomically
- Using the pool reserves, the DEX swaps the user's tokens for another (e.g. USDC → DAI).
- Everything happens in one atomic transaction, so it either succeeds or fails as a whole.
The uniform ERC-20 interface lets the DEX handle any compliant token without custom logic.
What You've Accomplished
You've just gained a superpower in Web3 development! You now know how to:
- Understand the fundamental ERC-20 token standard and its importance.
2. Deploy your own custom ERC-20 token for testing.
3. Use web3.py
to:
- Query token details like
name
,symbol
,decimals
, andtotalSupply
. - Check
balanceOf
any address. - Perform
transfer
transactions, directly sending tokens. - Set
approve
allowances for other addresses/contracts, a critical step for dApp interactions. - Check
allowance
amounts.
Useful Resources:
- OpenZeppelin ERC-20 Implementation --- Production-ready contracts
- EIP-20 Specification --- Official ERC-20 standard
- Token Lists --- Discover existing ERC-20 tokens
Follow for more blockchain development tutorials and share your token creations in the comments! 🚀
Having issues with your implementation? Drop your error messages below and I'll help you debug!
Top comments (0)