DEV Community

Cover image for Part 6: ERC-20 Tokens with web3.py - Balances, Transfers, and Approvals
Divine Igbinoba
Divine Igbinoba

Posted on

Part 6: ERC-20 Tokens with web3.py - Balances, Transfers, and Approvals

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)

  1. **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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.

  1. Before clicking "Deploy":
  • Next to the "Deploy" button, you'll see a field for the constructor arguments. Our SimpleERC20 constructor takes initialSupply as a uint256.
  • Enter a large number here for the initial supply, e.g., 1000000000000000000000000 (which is 1,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.")

Enter fullscreen mode Exit fullscreen mode

You'll see output detailing:

  1. 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):

  1. 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:

  1. 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, and totalSupply.
  • 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:


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)