The Ethereum concepts every web developer needs: accounts, transactions, smart contracts, ABI encoding, ethers.js, WAGMI, and reading on-chain data without running your own node.
Most "Ethereum for developers" content falls into two categories: oversimplified analogies that don't help you build anything, or deep protocol specifications that assume you already know what a Merkle Patricia Trie is. Neither is useful if you're a web developer who wants to read a token balance, let a user sign a transaction, or display NFT metadata in a React app.
This post is the practical middle ground. I'm going to explain exactly what happens when your frontend talks to Ethereum, what the moving parts are, and how the modern tooling (ethers.js, viem, WAGMI) maps to concepts you already understand from building web applications.
No metaphors about vending machines. No "imagine a world where..." Just the technical model and the code.
Ethereum is a replicated state machine. Every node in the network maintains an identical copy of the state — a massive key-value store that maps addresses to account data. When you "send a transaction," you're proposing a state transition. If enough validators agree it's valid, the state updates. That's it.
The state itself is straightforward. It's a mapping from 20-byte addresses to account objects. Every account has four fields:
There are two types of accounts, and the distinction matters for everything that follows:
Externally Owned Accounts (EOAs) are controlled by a private key. They're what MetaMask manages. They can initiate transactions. They have no code. When someone says "wallet," they mean an EOA.
Contract Accounts are controlled by their code. They can't initiate transactions — they can only execute in response to being called. They have code and storage. When someone says "smart contract," they mean this. The code is immutable once deployed (with some exceptions via proxy patterns, which are a whole other discussion).
The critical insight: every state change on Ethereum starts with an EOA signing a transaction. Contracts can call other contracts, but the chain of execution always begins with a human (or a bot) with a private key.
Every operation in the EVM costs gas. Adding two numbers costs 3 gas. Storing a 32-byte word costs 20,000 gas (first time) or 5,000 gas (update). Reading storage costs 2,100 gas (cold) or 100 gas (warm, already accessed in this transaction).
You don't pay gas in "gas units." You pay in ETH. The total cost is:
totalCost = gasUsed * gasPrice
After EIP-1559 (London upgrade), gas pricing became a two-part system:
totalCost = gasUsed * (baseFee + priorityFee)
If baseFee + priorityFee > maxFeePerGas, your transaction waits until baseFee drops. This is why transactions "get stuck" during high congestion.
The practical implication for web developers: reading data is free. Writing data costs money. This is the single most important architectural difference between Web2 and Web3. Every SELECT is free. Every INSERT, UPDATE, DELETE costs real money. Design your dApps accordingly.
A transaction is a signed data structure. Here are the fields that matter:
interface Transaction {
// Who receives this transaction — an EOA address or a contract address
to: string; // 20-byte hex address, or null for contract deployment
// How much ETH to send (in wei)
value: bigint; // Can be 0n for pure contract calls
// Encoded function call data, or empty for plain ETH transfers
data: string; // Hex-encoded bytes, "0x" for simple transfers
// Sequential counter, prevents replay attacks
nonce: number; // Must exactly equal sender's current nonce
// Gas limit — maximum gas this tx can consume
gasLimit: bigint;
// EIP-1559 fee parameters
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Chain identifier (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}Construction: Your app builds the transaction object. If you're calling a contract function, the data field contains the ABI-encoded function call (more on this below).
Signing: The private key signs the RLP-encoded transaction, producing v, r, s signature components. This proves the sender authorized this specific transaction. The sender address is derived from the signature — it's not explicitly in the transaction.
Broadcasting: The signed transaction gets sent to an RPC node via eth_sendRawTransaction. The node validates it (correct nonce, sufficient balance, valid signature) and adds it to its mempool.
Mempool: The transaction sits in a pool of pending transactions. Validators select transactions to include in the next block, generally preferring higher tips. This is where front-running happens — other actors can see your pending transaction and submit their own with a higher tip to execute before yours.
Inclusion: A validator includes your transaction in a block. The EVM executes it. If it succeeds, state changes are applied. If it reverts, state changes are rolled back — but you still pay for the gas consumed up to the revert point.
Finality: On proof-of-stake Ethereum, a block becomes "finalized" after two epochs (~12.8 minutes). Before finality, chain reorganizations are theoretically possible (though rare). Most apps treat 1-2 block confirmations as "good enough" for non-critical operations.
Here's what sending a simple ETH transfer looks like with ethers.js v6:
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
const tx = await wallet.sendTransaction({
to: "0xRecipientAddress...",
value: ethers.parseEther("0.1"), // Converts "0.1" to wei (100000000000000000n)
});
console.log("Tx hash:", tx.hash);
// Wait for inclusion in a block
const receipt = await tx.wait();
console.log("Block number:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = success, 0 = revertAnd the same with viem:
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const client = createWalletClient({
account,
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
const hash = await client.sendTransaction({
to: "0xRecipientAddress...",
value: parseEther("0.1"),
});
console.log("Tx hash:", hash);Notice the difference: ethers returns a TransactionResponse object with a .wait() method. Viem returns just the hash — you use a separate publicClient.waitForTransactionReceipt({ hash }) call to wait for confirmation. This separation of concerns is intentional in viem's design.
A smart contract is deployed bytecode plus persistent storage at a specific address. When you "call" a contract, you're sending a transaction (or making a read-only call) with the data field set to an encoded function invocation.
The bytecode is the compiled EVM code. You don't interact with it directly. It's what the EVM executes.
The ABI (Application Binary Interface) is a JSON description of the contract's interface. It tells your client library how to encode function calls and decode return values. Think of it as an OpenAPI spec for a contract.
Here's a fragment of an ERC-20 token ABI:
const ERC20_ABI = [
// Read-only functions (view/pure — no gas cost when called externally)
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
// State-changing functions (require a transaction, cost gas)
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
// Events (emitted during execution, stored in transaction logs)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;Ethers.js accepts this "human-readable ABI" format. Viem can also use it, but often you'll work with the full JSON ABI generated by the Solidity compiler. Both are equivalent — the human-readable format is just more convenient for common interfaces.
This is the part most tutorials skip, and it's the part that will save you hours of debugging.
When you call transfer("0xBob...", 1000000), the data field of the transaction gets set to:
0xa9059cbb // Function selector
0000000000000000000000000xBob...000000000000000000000000 // address, padded to 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 amount (1000000 in hex)
The function selector is the first 4 bytes of the Keccak-256 hash of the function signature:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = first 4 bytes = 0xa9059cbb
The remaining bytes are the ABI-encoded arguments, each padded to 32 bytes. This encoding scheme is deterministic — the same function call always produces the same calldata.
Why does this matter? Because when you see raw transaction data on Etherscan and it starts with 0xa9059cbb, you know it's a transfer call. When your transaction reverts and the error message is just a hex blob, you can decode it using the ABI. And when you're building transaction batches or interacting with multicall contracts, you'll be encoding calldata manually.
Here's how to encode and decode manually with ethers.js:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// Encode a function call
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// Decode calldata back to function name and args
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name); // "transfer"
console.log(decoded.args[0]); // "0xBobAddress..."
console.log(decoded.args[1]); // 1000000n (BigInt)
// Decode a function's return data
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueContract storage is a key-value store where both keys and values are 32 bytes. Solidity assigns storage slots sequentially starting from 0. The first declared state variable goes in slot 0, the next in slot 1, and so on. Mappings and dynamic arrays use a hash-based scheme.
You can read any contract's storage directly, even if the variable is marked private in Solidity. "Private" only means other contracts can't read it — anyone can read it via eth_getStorageAt:
// Reading storage slot 0 of a contract
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Raw 32-byte hex valueThis is how block explorers show "internal" contract state. There's no access control on storage reads. Privacy on a public blockchain is fundamentally limited.
Events are the contract's way of emitting structured data that gets stored in transaction logs but not in contract storage. They're cheaper than storage writes (375 gas for the first topic + 8 gas per byte of data, vs 20,000 gas for a storage write) and they're designed to be queried efficiently.
An event can have up to 3 indexed parameters (stored as "topics") and any number of non-indexed parameters (stored as "data"). Indexed parameters can be filtered on — you can ask "give me all Transfer events where to is this address." Non-indexed parameters can't be filtered; you have to fetch all matching events and filter client-side.
// Listening for Transfer events in real-time with ethers.js
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
contract.on("Transfer", (from, to, value, event) => {
console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} tokens`);
console.log("Block:", event.log.blockNumber);
console.log("Tx hash:", event.log.transactionHash);
});
// Querying historical events
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=any, to=specific
const events = await contract.queryFilter(filter, 19000000, 19100000); // block range
for (const event of events) {
console.log("From:", event.args.from);
console.log("Value:", event.args.value.toString());
}The same with viem:
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
// Historical logs
const logs = await client.getLogs({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: {
to: "0xMyAddress...",
},
fromBlock: 19000000n,
toBlock: 19100000n,
});
for (const log of logs) {
console.log("From:", log.args.from);
console.log("To:", log.args.to);
console.log("Value:", log.args.value);
}
// Real-time watching
const unwatch = client.watchEvent({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
onLogs: (logs) => {
for (const log of logs) {
console.log(`Transfer: ${log.args.from} -> ${log.args.to}`);
}
},
});
// Call unwatch() to stop listeningThis is where Ethereum becomes practical for web developers. You don't need to run a node. You don't need to mine. You don't even need a wallet. Reading data from Ethereum is free, permissionless, and works via a simple JSON-RPC API.
Every Ethereum node exposes a JSON-RPC API. It's literally HTTP POST with JSON bodies. There's nothing blockchain-specific about the transport layer.
// This is what your library does under the hood
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_call",
params: [
{
to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
// balanceOf(vitalik.eth)
},
"latest",
],
}),
});
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }That's a raw eth_call. It simulates a transaction execution without actually submitting it. No gas cost. No state change. Just reads the return value. This is how view and pure functions work from the outside — they use eth_call instead of eth_sendRawTransaction.
eth_call: Simulates execution. Free. No state change. Used for all read operations — checking balances, reading prices, calling view functions. Can be called on any historical block by specifying a block number instead of "latest".
eth_sendRawTransaction: Submits a signed transaction for inclusion in a block. Costs gas. Changes state (if successful). Used for all write operations — transfers, approvals, swaps, mints.
Everything else in the JSON-RPC API is either a variant of these two or a utility method (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, etc.).
You don't run your own node. Almost nobody does for application development. Instead, you use a provider service:
https://rpc.ankr.com/eth, https://cloudflare-eth.com. Free but rate-limited and occasionally unreliable. Fine for development, dangerous for production.For production, always configure at least two providers as fallbacks. RPC downtime is real and it will happen at the worst possible time.
import { ethers } from "ethers";
// ethers.js v6 fallback provider
const provider = new ethers.FallbackProvider([
{
provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
priority: 1,
stallTimeout: 2000,
weight: 2,
},
{
provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
priority: 2,
stallTimeout: 2000,
weight: 1,
},
]);This is the power move that most Web2 developers don't realize: you can read any public data from any contract on Ethereum without paying anything, without a wallet, and without any authentication beyond an API key for your RPC provider.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// ERC-20 interface — just the read functions
const erc20 = new ethers.Contract(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
[
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
],
provider // Note: provider, not signer. Read-only.
);
const [name, symbol, decimals, totalSupply] = await Promise.all([
erc20.name(),
erc20.symbol(),
erc20.decimals(),
erc20.totalSupply(),
]);
console.log(`${name} (${symbol})`); // "USD Coin (USDC)"
console.log(`Decimals: ${decimals}`); // 6 (NOT 18!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
// Check a specific address's balance
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);No wallet. No gas. No transaction. Just a JSON-RPC eth_call under the hood. This is identical in concept to making a GET request to a REST API. The blockchain is the database, the contract is the API, and eth_call is your SELECT query.
ethers.js is the jQuery of Web3 — it was the first library most developers learned, and it's still the most widely used. Version 6 is a significant improvement over v5, with native BigInt support (finally), ESM modules, and a cleaner API.
Provider: A read-only connection to the blockchain. Can call view functions, fetch blocks, read logs. Cannot sign or send transactions.
import { ethers } from "ethers";
// Connect to a node
const provider = new ethers.JsonRpcProvider("https://...");
// Basic queries
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer: An abstraction over a private key. Can sign transactions and messages. A Signer is always connected to a Provider.
// From a private key (server-side, scripts)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// From a browser wallet (client-side)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Get the address
const address = await signer.getAddress();Contract: A JavaScript proxy for a deployed contract. Methods on the Contract object correspond to functions in the ABI. View functions return values. State-changing functions return a TransactionResponse.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// Read (free, returns value directly)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance is a bigint: 1000000000n (1000 USDC with 6 decimals)
// To write, connect with a signer
const usdcWithSigner = usdc.connect(signer);
// Write (costs gas, returns TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Wait for block inclusion
if (receipt.status === 0) {
throw new Error("Transaction reverted");
}Raw ABI interactions are stringly-typed. You can misspell a function name, pass the wrong argument types, or misinterpret return values. TypeChain generates TypeScript types from your ABI files:
// Without TypeChain — no type checking
const balance = await contract.balanceOf("0x...");
// balance is 'any'. No autocomplete. Easy to misuse.
// With TypeChain — full type safety
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance is BigNumber. Autocomplete works. Type errors caught at compile time.For new projects, consider using viem's built-in type inference from ABIs instead. It achieves the same result without a separate code generation step.
Real-time event streaming is critical for responsive dApps. ethers.js uses WebSocket providers for this:
// WebSocket for real-time events
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
// Listen for all Transfer events
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}`);
console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
});
// Listen for transfers TO a specific address
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Incoming transfer: ${ethers.formatUnits(value, 6)} USDC from ${from}`);
});
// Clean up when done
contract.removeAllListeners();WAGMI (We're All Gonna Make It) is a React hooks library for Ethereum. Viem is the underlying TypeScript client it uses. Together, they've largely replaced ethers.js + web3-react as the standard stack for frontend dApp development.
Why the shift? Three reasons: full TypeScript inference from ABIs (no codegen needed), smaller bundle size, and React hooks that handle the messy async state management of wallet interactions.
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
export const config = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected(),
walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
],
transports: {
[mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
[sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
},
});// app/providers.tsx
"use client";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
);
}useReadContract is the hook you'll use most. It wraps eth_call with React Query caching, refetching, and loading/error states:
"use client";
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
const ERC20_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "owner", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
] as const;
function TokenBalance({ address }: { address: `0x${string}` }) {
const { data: balance, isLoading, error } = useReadContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
abi: ERC20_ABI,
functionName: "balanceOf",
args: [address],
});
if (isLoading) return <span>Loading...</span>;
if (error) return <span>Error: {error.message}</span>;
// balance is typed as bigint because the ABI says uint256
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}Notice the as const on the ABI. This is critical. Without it, TypeScript loses the literal types and balance becomes unknown instead of bigint. The entire type inference system depends on const assertions.
useWriteContract handles the full lifecycle: wallet prompt, signing, broadcasting, and confirmation tracking.
"use client";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
function SendTokens() {
const { writeContract, data: hash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
});
function handleSend() {
writeContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
functionName: "transfer",
args: [
"0xRecipientAddress...",
parseUnits("100", 6), // 100 USDC
],
});
}
return (
<div>
<button onClick={handleSend} disabled={isPending}>
{isPending ? "Confirm in wallet..." : "Send 100 USDC"}
</button>
{hash && <p>Transaction: {hash}</p>}
{isConfirming && <p>Waiting for confirmation...</p>}
{isSuccess && <p>Transfer confirmed!</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
}useWatchContractEvent sets up a WebSocket subscription for real-time event monitoring:
"use client";
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
interface TransferEvent {
from: string;
to: string;
value: string;
}
function LiveTransfers() {
const [transfers, setTransfers] = useState<TransferEvent[]>([]);
useWatchContractEvent({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
eventName: "Transfer",
onLogs(logs) {
const newTransfers = logs.map((log) => ({
from: log.args.from as string,
to: log.args.to as string,
value: formatUnits(log.args.value as bigint, 6),
}));
setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
},
});
return (
<ul>
{transfers.map((t, i) => (
<li key={i}>
{t.from.slice(0, 8)}... → {t.to.slice(0, 8)}...: {t.value} USDC
</li>
))}
</ul>
);
}Connecting a user's wallet is the "login" of Web3. Except it's not login. There's no session, no cookie, no server-side state. The wallet connection gives your app permission to read the user's address and request transaction signatures. That's it.
Every wallet exposes a standard interface defined by EIP-1193. It's an object with a request method:
interface EIP1193Provider {
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
on(event: string, handler: (...args: unknown[]) => void): void;
removeListener(event: string, handler: (...args: unknown[]) => void): void;
}MetaMask injects this as window.ethereum. Other wallets either inject their own property or also use window.ethereum (which causes conflicts — the "wallet wars" problem, partially solved by EIP-6963).
// Low-level wallet interaction (you shouldn't do this directly, but it's useful to understand)
// Request account access
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Connected address:", accounts[0]);
// Get the current chain
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
// Listen for account changes (user switches accounts in MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Wallet disconnected");
} else {
console.log("Switched to:", accounts[0]);
}
});
// Listen for chain changes (user switches networks)
window.ethereum.on("chainChanged", (chainId: string) => {
// The recommended approach is to reload the page
window.location.reload();
});The old window.ethereum approach breaks when users have multiple wallets installed. Which one gets window.ethereum? The last one to inject? The first? It's a race condition.
EIP-6963 fixes this with a discovery protocol based on browser events:
// Discovering all available wallets
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // Reverse domain name, e.g., "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// Request all wallets to announce themselves
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Now 'wallets' contains all installed wallets with their names and icons
// You can show a wallet selection UIWAGMI handles all of this for you. When you use the injected() connector, it automatically uses EIP-6963 if available and falls back to window.ethereum.
WalletConnect is a protocol that connects mobile wallets to desktop dApps via a relay server. The user scans a QR code with their mobile wallet, establishing an encrypted connection. Transaction requests get relayed from your dApp to their phone.
With WAGMI, it's just another connector:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Get from cloud.walletconnect.com
showQrModal: true,
});Users are often on the wrong network. Your dApp is on Mainnet, they're connected to Sepolia. Or they're on Polygon and you need Mainnet. WAGMI provides useSwitchChain:
"use client";
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
function NetworkGuard({ children }: { children: React.ReactNode }) {
const { chain } = useAccount();
const { switchChain, isPending } = useSwitchChain();
if (!chain) return <p>Please connect your wallet</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Please switch to Ethereum Mainnet</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "Switching..." : "Switch Network"}
</button>
</div>
);
}
return <>{children}</>;
}NFTs don't store images on-chain. The blockchain stores a URI that points to a JSON metadata file, which in turn contains a URL to the image. The standard pattern, defined by ERC-721's tokenURI function:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
That JSON file follows a standard schema:
{
"name": "Cool NFT #42",
"description": "A very cool NFT",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
]
}IPFS addresses use Content Identifiers (CIDs) — hashes of the content itself. ipfs://QmXyz... means "the content whose hash is QmXyz...". This is content-addressed storage: the URI is derived from the content, so the content can never change without changing the URI. This is the immutability guarantee that NFTs rely on (when they actually use IPFS — many use centralized URLs instead, which is a red flag).
To display IPFS content in a browser, you need a gateway that translates IPFS URIs to HTTP:
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// Or use a dedicated gateway:
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// Fetching NFT metadata
async function getNftMetadata(
contractAddress: string,
tokenId: bigint,
provider: ethers.Provider
) {
const contract = new ethers.Contract(
contractAddress,
["function tokenURI(uint256 tokenId) view returns (string)"],
provider
);
const tokenUri = await contract.tokenURI(tokenId);
const httpUri = ipfsToHttp(tokenUri);
const response = await fetch(httpUri);
const metadata = await response.json();
return {
name: metadata.name,
description: metadata.description,
image: ipfsToHttp(metadata.image),
attributes: metadata.attributes,
};
}IPFS is a peer-to-peer network. Content only stays available as long as someone is hosting ("pinning") it. If you upload an NFT image to IPFS and then shut down your node, the content disappears.
Pinning services keep your content available:
// Uploading to Pinata
async function pinToIpfs(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${PINATA_JWT}`,
},
body: formData,
});
const result = await response.json();
return `ipfs://${result.IpfsHash}`; // Returns CID
}Here's the dirty secret of blockchain development: you can't efficiently query historical data from an RPC node.
Want all Transfer events for a token in the last year? You'll need to scan millions of blocks with eth_getLogs, paginating in chunks of 2,000-10,000 blocks (the maximum varies by provider). That's thousands of RPC calls. It will take minutes to hours and burn through your API quota.
Want all tokens owned by a specific address? There's no single RPC call for this. You'd need to scan every Transfer event for every ERC-20 contract, tracking balances. That's not feasible.
Want all NFTs in a wallet? Same problem. You need to scan every ERC-721 Transfer event across every NFT contract.
The blockchain is a write-optimized data structure. It's excellent at processing new transactions. It's terrible at answering historical queries. This is the fundamental mismatch between what dApp UIs need and what the chain provides natively.
The Graph is a decentralized indexing protocol. You write a "subgraph" — a schema and a set of event handlers — and The Graph indexes the chain and serves the data via a GraphQL API.
# Subgraph schema (schema.graphql)
type Transfer @entity {
id: Bytes!
from: Bytes!
to: Bytes!
value: BigInt!
blockNumber: BigInt!
timestamp: BigInt!
}
type Account @entity {
id: Bytes!
balance: BigInt!
transfersFrom: [Transfer!]! @derivedFrom(field: "from")
transfersTo: [Transfer!]! @derivedFrom(field: "to")
}// Querying a subgraph from your frontend
const SUBGRAPH_URL =
"https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
async function getRecentTransfers(address: string) {
const query = `
query GetTransfers($address: Bytes!) {
transfers(
where: { from: $address }
orderBy: blockNumber
orderDirection: desc
first: 100
) {
id
from
to
value
blockNumber
timestamp
}
}
`;
const response = await fetch(SUBGRAPH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { address } }),
});
const { data } = await response.json();
return data.transfers;
}The tradeoff: The Graph adds latency (typically 1-2 blocks behind the chain head) and another dependency. The decentralized network also has indexing costs (you pay in GRT tokens). For smaller projects, the hosted service (Subgraph Studio) is free.
If you don't want to maintain a subgraph, both Alchemy and Moralis offer pre-indexed APIs that answer common queries directly:
// Alchemy: Get all ERC-20 token balances for an address
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "alchemy_getTokenBalances",
params: ["0xAddress...", "erc20"],
}),
}
);
// Returns ALL ERC-20 token balances in one call
// vs. scanning every possible ERC-20 contract's balanceOf()// Alchemy: Get all NFTs owned by an address
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}These APIs are proprietary and centralized. You're trading decentralization for developer experience. For most dApps, that's a worthwhile tradeoff. Your users don't care if their portfolio view comes from a subgraph or from Alchemy's database. They care that it loads in 200ms instead of 30 seconds.
After shipping several production dApps and debugging other teams' code, these are the mistakes I see repeatedly. Every single one has bitten me personally.
Ethereum deals in very large numbers. ETH balances are in wei (10^18). Token supplies can be 10^27 or higher. JavaScript Number can only safely represent integers up to 2^53 - 1 (about 9 * 10^15). That's not enough for wei amounts.
// WRONG — silent precision loss
const balance = 1000000000000000000; // 1 ETH in wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — looks right, but...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — WRONG! Rounded up.
console.log(largeBalance === 100000000000000000000); // true — data corruption
// RIGHT — use BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — correct
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — correctRules for BigInt in dApp code:
Number. Use BigInt everywhere, convert to human-readable strings only for display.Math.floor, Math.round, etc. on BigInts. They don't work. Use integer division: amount / 10n ** 6n.JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v).ethers.formatEther(), ethers.formatUnits(), viem's formatEther(), formatUnits(). They handle the conversion correctly.import { formatUnits, parseUnits } from "viem";
// Display: BigInt → human-readable string
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// Input: human-readable string → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDC has 6 decimals, not 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"Every wallet interaction is async and can fail in ways your app needs to handle gracefully:
// The user can reject any wallet prompt
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// User rejected the transaction in their wallet
// This is normal — not an error to report
showToast("Transaction cancelled");
} else if (error.code === -32603) {
// Internal JSON-RPC error — often means the transaction would revert
showToast("Transaction would fail. Check your balance.");
} else {
// Unexpected error
console.error("Transaction error:", error);
showToast("Something went wrong. Please try again.");
}
}Key async pitfalls:
await in your code can take 30 seconds while the user reads the transaction details in MetaMask. Don't show a loading spinner that makes them think something is broken.approve + execute. The user needs to sign two transactions. If they approve but don't execute, you need to check the allowance state and skip the approval step next time.This one wastes more debugging time than any other issue. Your contract is on Mainnet. Your wallet is on Sepolia. Your RPC provider points to Polygon. Three different networks, three different states, three completely unrelated blockchains. And the error message is usually unhelpful — "execution reverted" or "contract not found."
// Defensive chain checking
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "Please connect your wallet" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `Please switch to ${getChainName(requiredChainId)}. You're on ${getChainName(chainId)}.`,
};
}
return { ready: true, error: null };
}When you submit a swap on a DEX, your pending transaction is visible in the mempool. A bot can see your trade, front-run it by pushing the price up, let your trade execute at a worse price, and then sell immediately after for a profit. This is called a "sandwich attack."
As a frontend developer, you can't prevent this entirely, but you can mitigate it:
// Setting slippage tolerance on a Uniswap-style swap
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5% slippage tolerance
// Using a deadline to prevent long-lived pending transactions
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 minutes
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // Minimum acceptable output — revert if we'd get less
[tokenA, tokenB],
userAddress,
deadline, // Revert if not executed within 20 minutes
);For high-value transactions, consider using Flashbots Protect RPC, which sends transactions directly to block builders instead of the public mempool. This prevents sandwich attacks entirely because bots never see your pending transaction:
// Using Flashbots Protect as your RPC endpoint
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Not all tokens have 18 decimals. USDC and USDT have 6. WBTC has 8. Some tokens have 0, 2, or arbitrary decimals. Always read the decimals() from the contract before formatting amounts:
async function formatTokenAmount(
tokenAddress: string,
rawAmount: bigint,
provider: ethers.Provider
): Promise<string> {
const contract = new ethers.Contract(
tokenAddress,
["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
provider
);
const [decimals, symbol] = await Promise.all([
contract.decimals(),
contract.symbol(),
]);
return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"When estimateGas fails, it usually means the transaction would revert. But the error message is often just "cannot estimate gas" with no indication of why. Use eth_call to simulate the transaction and get the actual revert reason:
import { createPublicClient, http, decodeFunctionResult } from "viem";
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
try {
await client.call({
account: tx.from,
to: tx.to,
data: tx.data,
value: tx.value,
});
return null; // No error — transaction would succeed
} catch (error) {
// Decode the revert reason
if (error.data) {
// Common revert strings
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — standard revert with message
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert: ${reason[0]}`;
}
}
return error.message;
}
}Here's a complete, minimal React component that connects a wallet, reads a token balance, and sends a transfer. This is the skeleton of every dApp:
"use client";
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USDC_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
export function TokenDashboard() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
// Read balance — only runs when address is defined
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// Write: transfer tokens
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// Wait for confirmation
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// Refetch balance after confirmation
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
Connect Wallet
</button>
);
}
return (
<div>
<p>Connected: {address}</p>
<p>
USDC Balance:{" "}
{balance !== undefined ? formatUnits(balance, 6) : "Loading..."}
</p>
<div>
<input
placeholder="Recipient address (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="Amount (e.g., 100)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button
onClick={() => {
writeContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "transfer",
args: [recipient as `0x${string}`, parseUnits(amount, 6)],
});
}}
disabled={isSigning || isConfirming}
>
{isSigning
? "Confirm in wallet..."
: isConfirming
? "Confirming..."
: "Send USDC"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>Transfer confirmed!</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
View on Etherscan
</a>
)}
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}This post covered the essential concepts and tooling for web developers getting into Ethereum. There's a lot more depth in each area:
The blockchain ecosystem moves fast, but the fundamentals in this post — accounts, transactions, ABI encoding, RPC calls, event indexing — haven't changed since 2015 and won't change anytime soon. Learn these well and everything else is just API surface.