Introduction
Ethers.js v6 is the go-to JavaScript/TypeScript library for interacting with Ethereum and its L2s in 2026. Unlike the more verbose Web3.js, Ethers.js shines with its lightweight footprint (200kB gzipped), native typing, and intuitive APIs for providers, signers, and contracts. This advanced tutorial targets senior developers: we go beyond basics to implement custom providers, hierarchical deterministic (HD) wallets, batch interactions via Multicall3, filtered event listeners, and ENS resolution. Why it matters? Modern DApps handle thousands of requests: optimizing gas, batching calls, and monitoring mempools can boost performance 10x. On Sepolia (2026 testnet), all code works—copy it, set your Alchemy/Infura API keys, and run. Bookmark this guide for your blockchain audits. (142 words)
Prerequisites
- Node.js 20+ and npm/yarn/pnpm
- Advanced TypeScript knowledge, async/await, and Promises
- Alchemy or Infura API key for Sepolia RPC
- MetaMask wallet or private key for signing (use .env)
- Foundry/Hardhat for local testing (optional)
- ABI for an ERC-20 contract like USDC on Sepolia
Project Installation and Setup
mkdir ethers-advanced && cd ethers-advanced
npm init -y
npm install ethers@6 typescript ts-node @types/node dotenv
npm install -D @types/node
mkdir src
echo '{
"ts-node": {
"esm": true
},
"type": "module"
}' > package.json
mkdir .env
echo 'ALCHEMY_SEPOLIA_URL=https://eth-sepolia.g.alchemy.com/v2/VOTRE_CLE'
PRIVATE_KEY=0xVOTRE_CLE_PRIVEE' > .envThis script sets up an ESM project with Ethers v6 and TypeScript. Use dotenv to secure RPC secrets and private keys. Never hardcode keys in production to avoid Git leaks.
Connecting to a Custom JsonRpcProvider
import { JsonRpcProvider, formatEther } from 'ethers';
import dotenv from 'dotenv';
dotenv.config();
async function getProvider() {
const url = process.env.ALCHEMY_SEPOLIA_URL;
if (!url) throw new Error('ALCHEMY_SEPOLIA_URL manquante');
const provider = new JsonRpcProvider(url);
const blockNumber = await provider.getBlockNumber();
const block = await provider.getBlock(blockNumber);
console.log('Block number:', blockNumber);
console.log('Block timestamp:', new Date(block.timestamp * 1000).toISOString());
console.log('Gas price (ETH):', formatEther(await provider.getFeeData()).toString());
return provider;
}
getProvider().catch(console.error);This code creates an Alchemy provider for Sepolia, fetches the latest block and fee data. Think of it as a blockchain odometer—monitor fees to optimize transactions. Pitfall: forget 'ethers@6' for v5 breaking changes like toEther() now being formatEther().
Creating an HD Wallet with Derivation
import { Wallet, Mnemonic, derivePath } from 'ethers';
import { JsonRpcProvider } from 'ethers';
import dotenv from 'dotenv';
import { getProvider } from './provider.js';
dotenv.config();
async function createHDWallet() {
const provider = await getProvider();
const mnemonic = process.env.MNEMONIC || "test test test test test test test test test test test junk";
const hdNode = Mnemonic.toHdNode(mnemonic);
const path = "m/44'/60'/0'/0/0";
const childWallet = new Wallet(derivePath(path, mnemonic).privateKey, provider);
console.log('Address:', await childWallet.getAddress());
console.log('Balance ETH:', (await provider.getBalance(childWallet.address)).toString());
const nonce = await childWallet.getNonce('latest');
console.log('Nonce:', nonce);
return childWallet;
}
createHDWallet().catch(console.error);Generates a BIP44-derived HD wallet for multisig or sub-wallets. Why HD? Scalable for DeFi farms. Pitfall: wrong BIP path leads to incorrect address; always test balance >0 from Sepolia faucet.
Advanced Reading from ERC-20 Contract (USDC Sepolia)
import { Interface, JsonRpcProvider, parseUnits } from 'ethers';
import { getProvider } from './provider.js';
const USDC_SEPOLIA = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
const ERC20_ABI = [
'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)'
] as const;
async function readERC20(address: string) {
const provider = await getProvider();
const contract = new Interface(ERC20_ABI);
const calls = [
contract.encodeFunctionData('name'),
contract.encodeFunctionData('symbol'),
contract.encodeFunctionData('decimals'),
contract.encodeFunctionData('totalSupply'),
contract.encodeFunctionData('balanceOf', [address])
];
const results = await Promise.all(calls.map(call => provider.call({
to: USDC_SEPOLIA,
data: call
})));
const [name, symbol, decimals, totalSupply, balance] = results.map((data, i) =>
contract.decodeFunctionResult(calls[i] as any, data)[0]
);
console.log('Name:', name);
console.log('Symbol:', symbol);
console.log('Decimals:', decimals);
console.log('Total Supply:', (Number(totalSupply) / 10**Number(decimals)).toLocaleString());
console.log('Your Balance:', (Number(balance) / 10**Number(decimals)).toLocaleString());
}
readERC20('0x742d35Cc6634C0532925a3b8D7c22B32A251A800').catch(console.error);Reads USDC metadata and balances using minimal ABI and encodeFunctionData. Advanced: manual batch without Multicall for low-level control. Pitfall: forget decimals for formatting—crashes your UIs.
Writing to Contract: Transfer and Approve
import { parseUnits, Interface } from 'ethers';
import { Wallet } from 'ethers';
import dotenv from 'dotenv';
import { getProvider } from './provider.js';
dotenv.config();
const USDC_SEPOLIA = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
const ERC20_ABI = [
'function approve(address spender, uint256 amount) returns (bool)',
'function transfer(address to, uint256 amount) returns (bool)'
] as const;
async function writeERC20() {
const provider = await getProvider();
const wallet = new Wallet(process.env.PRIVATE_KEY as string, provider);
const contract = new Interface(ERC20_ABI);
const amount = parseUnits('100', 6);
const recipient = '0x742d35Cc6634C0532925a3b8D7c22B32A251A800';
const tx1 = await wallet.sendTransaction({
to: USDC_SEPOLIA,
data: contract.encodeFunctionData('transfer', [recipient, amount])
});
console.log('Transfer TX:', tx1.hash);
await tx1.wait();
console.log('Transfer mined!');
}
writeERC20().catch(console.error);Executes USDC transfer with low-level sendTransaction. For approve, swap to 'approve'. Like signing a check—nonce auto-managed. Pitfall: insufficient funds or missing allowance for transferFrom.
Real-Time Filtered Event Listeners
import { JsonRpcProvider, Interface } from 'ethers';
import { getProvider } from './provider.js';
const USDC_SEPOLIA = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
const ERC20_ABI = ['event Transfer(address indexed from, address indexed to, uint256 value)'] as const;
async function listenEvents() {
const provider = await getProvider();
const contract = new Interface(ERC20_ABI);
const filter = {
address: USDC_SEPOLIA,
topics: [contract.getEvent('Transfer').topicHash]
};
provider.on(filter, (log) => {
const parsed = contract.parseLog(log);
console.log('Transfer event:',
'From:', parsed.args[0],
'To:', parsed.args[1],
'Value:', parsed.args[2].toString()
);
});
console.log('Listening... Ctrl+C to stop');
}
listenEvents().catch(console.error);Captures USDC Transfer events with topic filters for efficiency. Advanced: underlying websockets for low-latency. Pitfall: missing off() leaks memory on restarts.
Batch Calls with Multicall3
import { JsonRpcProvider, Interface, parseUnits } from 'ethers';
import { getProvider } from './provider.js';
const MULTICALL3_SEPOLIA = '0xcA11bde05977b3631167028862bE2a173976CA11';
const USDC_SEPOLIA = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
const MULTICALL_ABI = [
'struct Call {address target; bytes callData;}
function aggregate3(Call[] calls) returns (bytes[] returnData);'
] as const;
const ERC20_ABI = ['function balanceOf(address) view returns (uint256)', 'function totalSupply() view returns (uint256)'];
async function multicall() {
const provider = await getProvider();
const multicall = new Interface(MULTICALL_ABI);
const erc20 = new Interface(ERC20_ABI);
const addresses = ['0x742d35Cc6634C0532925a3b8D7c22B32A251A800', '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'];
const calls = addresses.map(addr => ({
target: USDC_SEPOLIA,
callData: erc20.encodeFunctionData('balanceOf', [addr])
}));
const data = multicall.encodeFunctionData('aggregate3', [calls]);
const result = await provider.call({to: MULTICALL3_SEPOLIA, data});
const returns = multicall.decodeFunctionResult('aggregate3', result);
returns[0].forEach((ret: any, i: number) => {
console.log(`Balance ${addresses[i]}:`, ret.toString());
});
}
multicall().catch(console.error);Batches 10+ calls into 1 RPC via Multicall3—saves 90% bandwidth. Uses Call struct for aggregate3 (v3+ safe). Pitfall: static calls only, no writes; match ABI strictly.
Best Practices
- Secure keys: Use HSM or MPC in production, never commit .env to Git.
- Rate limiting: Implement retries with backoff on provider.send('eth_call', ...).
- Gas estimation: Always use populateTransaction before send to predict fees.
- Type safety: Extend Narrow
for fully typed ABI contracts. - Fallback providers: Chain JsonRpcProvider + Eip1193 for MetaMask.
Common Errors to Avoid
- Version mismatch: v6 drops utils.connect()—upgrade parseEther to formatEther.
- Nonce collision: Avoid 'pending' without locks in batch sends.
- Event leaks: Call provider.off(filter) on React/Vue unmount.
- BigInt overflow: Cast to Number() only after formatEther for UIs.
Next Steps
- Official docs: Ethers v6
- Advanced: Integrate Viem for hybrid libraries or TheGraph for subgraphs.
- Practice on Anvil (Foundry) to fork mainnet.