Reading Smart Contract Data with ethers.js v6: A Complete Beginner's Guide

Diluk AngeloDiluk Angelo
7 min read

Introduction

Welcome to Web3 development! If you're new to blockchain development, this guide will walk you through the fundamentals of reading data from smart contracts. We'll start with the basics and build up to creating a real-world application.

Prerequisites

  • Basic JavaScript/TypeScript knowledge

  • Node.js installed on your system

  • A code editor (VS Code recommended)

  • No prior blockchain experience needed!

Table of Contents

  1. Understanding Web3 Infrastructure

  2. Setting Up Your Development Environment

  3. Reading Smart Contract Data

  4. Building a Token Balance API

1. Understanding Web3 Infrastructure

What is a JSON-RPC?

JSON-RPC (Remote Procedure Call) is how we communicate with blockchain nodes. Think of it as an API endpoint for blockchain interactions. When you want to:

  • Read blockchain data

  • Send transactions

  • Deploy contracts You'll need to connect to a JSON-RPC endpoint.

Where to Get JSON-RPC Access?

You have several options:

  1. Node Providers (Recommended for Beginners):

  2. Running Your Own Node:

    • More advanced

    • Requires server maintenance

    • Complete control over your infrastructure

For this tutorial, we'll use Alchemy. Here's how to get started:

  1. Sign up at Alchemy.com

  2. Create a new app (choose Ethereum mainnet or Sepolia testnet)

  3. Get your API key and HTTP URL (looks like: https://eth-mainnet.g.alchemy.com/v2/YOUR-API-KEY)

Why Not Connect Directly to Ethereum?

  • Running a node is resource-intensive

  • Node providers offer:

    • High availability

    • Fast response times

    • Multiple network support

    • Developer tools

2. Setting Up Your Development Environment

First, let's create a new project and install dependencies:

mkdir smart-contract-reader
cd smart-contract-reader
npm init -y
npm install ethers dotenv typescript @types/node ts-node
npx tsc --init

Create a .env file:

ALCHEMY_URL=your_alchemy_url_here

Create a basic TypeScript configuration in tsconfig.json:

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

3. Reading Smart Contract Data

Understanding Smart Contracts

Smart contracts are like backend APIs deployed on the blockchain. To interact with them, we need:

  1. Contract Address (where the contract lives on the blockchain)

  2. ABI (Application Binary Interface - tells us how to interact with the contract)

  3. Provider (our connection to the blockchain)

Contract ABIs Explained

An ABI is like an API specification. It tells us:

  • What functions are available

  • What parameters they take

  • What data they return

For ERC20 tokens (the most common token type), here's a minimal ABI:

const ERC20_ABI = [
    // Read token balance
    "function balanceOf(address owner) view returns (uint256)",
    // Get token decimals
    "function decimals() view returns (uint8)",
    // Get token symbol
    "function symbol() view returns (string)",
    // Get token name
    "function name() view returns (string)"
];

Let's Write Some Code!

Create src/index.ts:

import { ethers } from 'ethers';
import dotenv from 'dotenv';

dotenv.config();

async function main() {
    try {
        // 1. Connect to the network
        const provider = new ethers.JsonRpcProvider(process.env.ALCHEMY_URL);

        // 2. Test connection by getting the latest block
        const blockNumber = await provider.getBlockNumber();
        console.log(`Current block number: ${blockNumber}`);

        // 3. Let's read some token data (using USDT as an example)
        const USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7';
        const ERC20_ABI = [
            "function balanceOf(address) view returns (uint256)",
            "function decimals() view returns (uint8)",
            "function symbol() view returns (string)"
        ];

        // 4. Create contract instance
        const contract = new ethers.Contract(USDT_ADDRESS, ERC20_ABI, provider);

        // 5. Read some basic token info
        const decimals = await contract.decimals();
        const symbol = await contract.symbol();

        console.log(`Token Symbol: ${symbol}`);
        console.log(`Token Decimals: ${decimals}`);

        // 6. Read balance of a specific address
        const walletAddress = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e';
        const balance = await contract.balanceOf(walletAddress);

        // 7. Format balance to human-readable format
        const formattedBalance = ethers.formatUnits(balance, decimals);
        console.log(`Balance: ${formattedBalance} ${symbol}`);

    } catch (error) {
        console.error('Error:', error);
    }
}

main();

Understanding the Code

Let's break down what's happening:

  1. Provider Setup:

     const provider = new ethers.JsonRpcProvider(process.env.ALCHEMY_URL);
    

    This creates our connection to Ethereum using our Alchemy URL.

  2. Contract Instance:

     const contract = new ethers.Contract(USDT_ADDRESS, ERC20_ABI, provider);
    

    This creates an interface to interact with the smart contract.

  3. Reading Data:

     const balance = await contract.balanceOf(walletAddress);
    

    This calls the balanceOf function on the smart contract.

  4. Formatting Data:

     const formattedBalance = ethers.formatUnits(balance, decimals);
    

    Converts the raw balance (in wei) to a human-readable format.

Understanding Big Numbers

Ethereum handles numbers as "BigNumbers" due to precision requirements. Example:

  • Raw balance might be: 1000000000000000000

  • With 18 decimals, this is actually: 1.0

That's why we use formatUnits and always need to know the token's decimals!

4. Building a Token Balance API

Now let's build something practical! We'll create an API that can:

  • Check multiple token balances

  • Handle errors gracefully

  • Return formatted results

Create src/api.ts:

import express from 'express';
import { ethers } from 'ethers';
import dotenv from 'dotenv';

dotenv.config();
const app = express();
app.use(express.json());

// Interfaces for type safety
interface TokenBalance {
    token: string;
    symbol: string;
    balanceWei: string;
    balanceFormatted: string;
    decimals: number;
}

interface TokenBalanceResponse {
    address: string;
    balances: TokenBalance[];
    error?: string;
}

// Initialize provider
const provider = new ethers.JsonRpcProvider(process.env.ALCHEMY_URL);

const ERC20_ABI = [
    "function balanceOf(address) view returns (uint256)",
    "function decimals() view returns (uint8)",
    "function symbol() view returns (string)"
];

app.post('/api/token-balances', async (req, res) => {
    try {
        const { addresses, tokens } = req.body;

        // Validate input
        if (!addresses || !tokens || !Array.isArray(addresses) || !Array.isArray(tokens)) {
            return res.status(400).json({ error: 'Invalid input. Provide addresses and tokens arrays.' });
        }

        const results: TokenBalanceResponse[] = [];

        // Process each address
        for (const address of addresses) {
            try {
                // Validate address format
                if (!ethers.isAddress(address)) {
                    throw new Error('Invalid address format');
                }

                const balances: TokenBalance[] = [];

                // Process each token
                for (const tokenAddress of tokens) {
                    try {
                        // Validate token address format
                        if (!ethers.isAddress(tokenAddress)) {
                            throw new Error('Invalid token address format');
                        }

                        const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);

                        // Fetch all token data in parallel
                        const [balance, decimals, symbol] = await Promise.all([
                            contract.balanceOf(address),
                            contract.decimals(),
                            contract.symbol()
                        ]);

                        balances.push({
                            token: tokenAddress,
                            symbol,
                            balanceWei: balance.toString(),
                            balanceFormatted: ethers.formatUnits(balance, decimals),
                            decimals
                        });
                    } catch (tokenError) {
                        console.error(`Error fetching token ${tokenAddress}:`, tokenError);
                        balances.push({
                            token: tokenAddress,
                            symbol: 'ERROR',
                            balanceWei: '0',
                            balanceFormatted: '0',
                            decimals: 0
                        });
                    }
                }

                results.push({
                    address,
                    balances
                });
            } catch (addressError) {
                console.error(`Error processing address ${address}:`, addressError);
                results.push({
                    address,
                    balances: [],
                    error: 'Failed to fetch balances for this address'
                });
            }
        }

        res.json(results);
    } catch (error) {
        console.error('Server error:', error);
        res.status(500).json({ error: 'Internal server error' });
    }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});

Testing the API

Use curl or Postman to test:

curl -X POST http://localhost:3000/api/token-balances \
-H "Content-Type: application/json" \
-d '{
    "addresses": ["0x742d35Cc6634C0532925a3b844Bc454e4438f44e"],
    "tokens": [
        "0xdac17f958d2ee523a2206206994597c13d831ec7",  # USDT
        "0x6b175474e89094c44da98b954eedeac495271d0f"   # DAI
    ]
}'

Common Challenges and Solutions

  1. Rate Limiting
// Add delay between requests
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
await delay(100); // Wait 100ms between requests
  1. Error Handling
try {
    const balance = await contract.balanceOf(address);
} catch (error) {
    if (error.code === 'CALL_EXCEPTION') {
        console.log('Contract call failed - possibly not an ERC20 token');
    }
}
  1. Gas Optimization
  • Use callStatic for simulating transactions

  • Batch multiple calls using multicall (advanced topic)

Best Practices

  1. Always Validate Addresses
if (!ethers.isAddress(address)) {
    throw new Error('Invalid address');
}
  1. Cache Contract Data Common data like decimals and symbols can be cached.

  2. Use Type Safety TypeScript interfaces help catch errors early.

  3. Handle Network Issues Implement retry logic for failed requests.

Conclusion

You've learned:

  • What JSON-RPC providers are and how to use them

  • How to read data from smart contracts

  • How to handle blockchain-specific data types

  • How to build a production-ready API

Next Steps

Try extending the API with:

  • Support for more token standards (ERC721, ERC1155)

  • Historical balance lookups

  • Price data integration

  • Multiple network support

Resources

Remember: Always test on testnets (like Sepolia) before deploying to mainnet!

1
Subscribe to my newsletter

Read articles from Diluk Angelo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Diluk Angelo
Diluk Angelo

Hey there! I'm Diluk Angelo, a Tech Lead and Web3 developer passionate about bridging the gap between traditional web solutions and the decentralized future. With years of leadership experience under my belt, I've guided teams and mentored developers in their technical journey. What really drives me is the art of transformation – taking proven Web2 solutions and reimagining them for the Web3 ecosystem while ensuring they remain scalable and efficient. Through this blog, I share practical insights from my experience in architecting decentralized solutions, leading technical teams, and navigating the exciting challenges of Web3 development. Whether you're a seasoned developer looking to pivot to Web3 or a curious mind exploring the possibilities of decentralized technology, you'll find actionable knowledge and real-world perspectives here. Expect deep dives into Web3 architecture, scalability solutions, team leadership in blockchain projects, and practical guides on transitioning from Web2 to Web3. I believe in making complex concepts accessible and sharing lessons learned from the trenches. Join me as we explore the future of the web, one block at a time!