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
Understanding Web3 Infrastructure
Setting Up Your Development Environment
Reading Smart Contract Data
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:
Where to Get JSON-RPC Access?
You have several options:
Node Providers (Recommended for Beginners):
Running Your Own Node:
For this tutorial, we'll use Alchemy. Here's how to get started:
Sign up at Alchemy.com
Create a new app (choose Ethereum mainnet or Sepolia testnet)
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?
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:
Contract Address (where the contract lives on the blockchain)
ABI (Application Binary Interface - tells us how to interact with the contract)
Provider (our connection to the blockchain)
Contract ABIs Explained
An ABI is like an API specification. It tells us:
For ERC20 tokens (the most common token type), here's a minimal ABI:
const ERC20_ABI = [
"function balanceOf(address owner) view returns (uint256)",
"function decimals() view returns (uint8)",
"function symbol() view returns (string)",
"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 {
const provider = new ethers.JsonRpcProvider(process.env.ALCHEMY_URL);
const blockNumber = await provider.getBlockNumber();
console.log(`Current block number: ${blockNumber}`);
const USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7';
const ERC20_ABI = [
"function balanceOf(address) view returns (uint256)",
"function decimals() view returns (uint8)",
"function symbol() view returns (string)"
];
const contract = new ethers.Contract(USDT_ADDRESS, ERC20_ABI, provider);
const decimals = await contract.decimals();
const symbol = await contract.symbol();
console.log(`Token Symbol: ${symbol}`);
console.log(`Token Decimals: ${decimals}`);
const walletAddress = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e';
const balance = await contract.balanceOf(walletAddress);
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:
Provider Setup:
const provider = new ethers.JsonRpcProvider(process.env.ALCHEMY_URL);
This creates our connection to Ethereum using our Alchemy URL.
Contract Instance:
const contract = new ethers.Contract(USDT_ADDRESS, ERC20_ABI, provider);
This creates an interface to interact with the smart contract.
Reading Data:
const balance = await contract.balanceOf(walletAddress);
This calls the balanceOf function on the smart contract.
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:
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:
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());
interface TokenBalance {
token: string;
symbol: string;
balanceWei: string;
balanceFormatted: string;
decimals: number;
}
interface TokenBalanceResponse {
address: string;
balances: TokenBalance[];
error?: string;
}
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;
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[] = [];
for (const address of addresses) {
try {
if (!ethers.isAddress(address)) {
throw new Error('Invalid address format');
}
const balances: TokenBalance[] = [];
for (const tokenAddress of tokens) {
try {
if (!ethers.isAddress(tokenAddress)) {
throw new Error('Invalid token address format');
}
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
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
- Rate Limiting
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
await delay(100);
- 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');
}
}
- Gas Optimization
Best Practices
- Always Validate Addresses
if (!ethers.isAddress(address)) {
throw new Error('Invalid address');
}
Cache Contract Data Common data like decimals and symbols can be cached.
Use Type Safety TypeScript interfaces help catch errors early.
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:
Resources
Remember: Always test on testnets (like Sepolia) before deploying to mainnet!