Complete reference for connecting smart contracts to frontend applications. All examples use the contracts from this repository as the target.
- Stack Overview
- Project Setup
- Connecting to a Wallet
- Reading Contract State
- Writing to Contracts (Transactions)
- Listening to Events
- ABI Encoding & Decoding
- ERC-20 Integration
- ERC-721 Integration
- ERC-1155 Integration
- ERC-4337 — Gasless Transactions
- Multicall — Batch Reads
- Error Handling
- Signing Messages & Typed Data (EIP-712)
- SIWE — Sign-In With Ethereum
- Simulating Transactions
- Gas Estimation
- Testing Frontend Integrations
- Security Best Practices
- Tooling & Ecosystem Cheatsheet
| Layer | ethers.js v6 | viem v2 | Notes |
|---|---|---|---|
| Philosophy | Class-based, batteries-included | Functional, tree-shakeable | viem has zero dependencies |
| TypeScript | Good | Excellent (type-safe ABIs) | viem generates types from ABI |
| Bundle size | ~130KB gzip | ~35KB gzip | viem wins for SPAs |
| Wagmi | v1 (legacy) | v2 (default) | wagmi wraps viem for React |
| Learning curve | Easier | Slightly steeper | Both excellent docs |
| Performance | Good | Better (uses native BigInt) | Both sufficient for most apps |
Recommendation (2025+):
- New projects → viem + wagmi
- Existing ethers.js projects → stay on ethers.js v6
- Node.js scripts/bots → either works; viem preferred for type safety
npm install ethers
# or
pnpm add ethersimport { ethers } from "ethers";
// Browser wallet (MetaMask, Rabby, etc.)
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
// Read-only RPC provider
const readProvider = new ethers.JsonRpcProvider(process.env.NEXT_PUBLIC_RPC_URL);
// WebSocket provider (for event subscriptions)
const wsProvider = new ethers.WebSocketProvider(process.env.NEXT_PUBLIC_WS_URL);npm install viem wagmi @tanstack/react-query// config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia, arbitrum, base } from "wagmi/chains";
export const config = createConfig({
chains: [mainnet, sepolia, arbitrum, base],
transports: {
[mainnet.id]: http(process.env.NEXT_PUBLIC_MAINNET_RPC),
[sepolia.id]: http(process.env.NEXT_PUBLIC_SEPOLIA_RPC),
[arbitrum.id]: http(process.env.NEXT_PUBLIC_ARBITRUM_RPC),
[base.id]: http(process.env.NEXT_PUBLIC_BASE_RPC),
},
});// app/providers.tsx
"use client";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./config";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
);
}// Request wallet connection
async function connectWallet() {
if (!window.ethereum) throw new Error("No wallet found");
const provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const network = await provider.getNetwork();
console.log("Connected:", address);
console.log("Chain ID:", network.chainId);
return { provider, signer, address };
}
// Switch to a specific chain
async function switchChain(chainId: number) {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: `0x${chainId.toString(16)}` }],
});
}
// Add a chain if not present (e.g., Arbitrum)
async function addArbitrum() {
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [{
chainId: "0xa4b1",
chainName: "Arbitrum One",
nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
rpcUrls: ["https://arb1.arbitrum.io/rpc"],
blockExplorerUrls: ["https://arbiscan.io"],
}],
});
}import { useConnect, useDisconnect, useAccount, useSwitchChain } from "wagmi";
import { injected, walletConnect } from "wagmi/connectors";
function ConnectButton() {
const { connect, connectors } = useConnect();
const { disconnect } = useDisconnect();
const { address, isConnected, chainId } = useAccount();
const { switchChain } = useSwitchChain();
if (isConnected) {
return (
<div>
<p>Connected: {address}</p>
<p>Chain: {chainId}</p>
<button onClick={() => switchChain({ chainId: 1 })}>Switch to Mainnet</button>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}
return (
<div>
{connectors.map((connector) => (
<button key={connector.id} onClick={() => connect({ connector })}>
Connect with {connector.name}
</button>
))}
</div>
);
}import { ethers } from "ethers";
import { ERC20_ABI } from "./abis"; // generated from forge build
const TOKEN_ADDRESS = "0x...";
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const token = new ethers.Contract(TOKEN_ADDRESS, ERC20_ABI, provider);
// Single call
const totalSupply: bigint = await token.totalSupply();
const balance: bigint = await token.balanceOf("0x...");
const name: string = await token.name();
const decimals: number = await token.decimals();
// Format for display
const formatted = ethers.formatUnits(balance, decimals);
console.log(`Balance: ${formatted} ${await token.symbol()}`);
// Read with block override (historical state)
const historicBalance = await token.balanceOf("0x...", { blockTag: 18_000_000 });import { createPublicClient, http, formatUnits } from "viem";
import { mainnet } from "viem/chains";
import { erc20Abi } from "viem"; // built-in standard ABIs
const TOKEN_ADDRESS = "0x..." as `0x${string}`;
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.RPC_URL),
});
// Single call
const totalSupply = await client.readContract({
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "totalSupply",
});
const balance = await client.readContract({
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "balanceOf",
args: ["0x..." as `0x${string}`],
});
// Read at historical block
const historicBalance = await client.readContract({
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "balanceOf",
args: ["0x..." as `0x${string}`],
blockNumber: 18_000_000n,
});import { useReadContract, useReadContracts } from "wagmi";
import { erc20Abi } from "viem";
import { formatUnits } from "viem";
const TOKEN = { address: "0x..." as `0x${string}`, abi: erc20Abi };
function TokenInfo({ userAddress }: { userAddress: `0x${string}` }) {
// Batch multiple reads in a single hook call
const { data, isLoading, error } = useReadContracts({
contracts: [
{ ...TOKEN, functionName: "name" },
{ ...TOKEN, functionName: "symbol" },
{ ...TOKEN, functionName: "decimals" },
{ ...TOKEN, functionName: "totalSupply" },
{ ...TOKEN, functionName: "balanceOf", args: [userAddress] },
],
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
const [name, symbol, decimals, totalSupply, balance] = data!;
return (
<div>
<h2>{name.result} ({symbol.result})</h2>
<p>Total Supply: {formatUnits(totalSupply.result!, decimals.result!)}</p>
<p>Your Balance: {formatUnits(balance.result!, decimals.result!)}</p>
</div>
);
}const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const token = new ethers.Contract(TOKEN_ADDRESS, ERC20_ABI, signer);
// Send transaction
const tx = await token.transfer("0xRecipient...", ethers.parseUnits("100", 18));
console.log("Tx hash:", tx.hash);
// Wait for confirmation
const receipt = await tx.wait(1); // 1 confirmation
console.log("Block:", receipt!.blockNumber);
console.log("Gas used:", receipt!.gasUsed);
// With custom gas settings
const txOverrides = await token.transfer("0xRecipient...", ethers.parseUnits("100", 18), {
gasLimit: BigInt(100_000),
maxFeePerGas: ethers.parseUnits("30", "gwei"),
maxPriorityFeePerGas: ethers.parseUnits("1", "gwei"),
});
// Send ETH directly
const ethTx = await signer.sendTransaction({
to: "0xRecipient...",
value: ethers.parseEther("0.1"),
});import { createWalletClient, createPublicClient, custom, parseUnits } from "viem";
import { mainnet } from "viem/chains";
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum),
});
const [account] = await walletClient.getAddresses();
// Write to contract
const txHash = await walletClient.writeContract({
address: TOKEN_ADDRESS,
abi: ERC20_ABI,
functionName: "transfer",
args: ["0xRecipient..." as `0x${string}`, parseUnits("100", 18)],
account,
});
// Wait for receipt
const publicClient = createPublicClient({ chain: mainnet, transport: http() });
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
console.log("Status:", receipt.status); // "success" | "reverted"
console.log("Gas used:", receipt.gasUsed);import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { erc20Abi, parseUnits } from "viem";
function TransferButton({ recipient, amount }: { recipient: `0x${string}`; amount: string }) {
const { writeContract, data: txHash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
const handleTransfer = () => {
writeContract({
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "transfer",
args: [recipient, parseUnits(amount, 18)],
});
};
return (
<div>
<button onClick={handleTransfer} disabled={isPending || isConfirming}>
{isPending ? "Waiting for wallet..." :
isConfirming ? "Confirming..." :
"Transfer"}
</button>
{txHash && <p>Tx: {txHash}</p>}
{isSuccess && <p>Transfer confirmed!</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
}const token = new ethers.Contract(TOKEN_ADDRESS, ERC20_ABI, provider);
// Listen to all Transfer events
token.on("Transfer", (from, to, value, event) => {
console.log("Transfer:", {
from,
to,
value: ethers.formatUnits(value, 18),
block: event.log.blockNumber,
hash: event.log.transactionHash,
});
});
// Listen to filtered events (only transfers TO a specific address)
const filter = token.filters.Transfer(null, "0xMyAddress...");
token.on(filter, (from, to, value) => {
console.log(`Received ${ethers.formatUnits(value, 18)} tokens from ${from}`);
});
// Stop listening
token.removeAllListeners("Transfer");
// Query historical events
const pastEvents = await token.queryFilter(
token.filters.Transfer(),
18_000_000, // fromBlock
18_100_000 // toBlock
);
pastEvents.forEach((e) => {
const decoded = e as ethers.EventLog;
console.log(decoded.args.from, "->", decoded.args.to, decoded.args.value);
});// Subscribe to live events (WebSocket)
const wsClient = createPublicClient({
chain: mainnet,
transport: webSocket(process.env.WS_RPC_URL),
});
const unwatch = wsClient.watchContractEvent({
address: TOKEN_ADDRESS,
abi: erc20Abi,
eventName: "Transfer",
onLogs: (logs) => {
logs.forEach((log) => {
console.log("Transfer:", log.args.from, "->", log.args.to, log.args.value);
});
},
});
// Stop watching
unwatch();
// Query historical logs
const logs = await publicClient.getContractEvents({
address: TOKEN_ADDRESS,
abi: erc20Abi,
eventName: "Transfer",
fromBlock: 18_000_000n,
toBlock: 18_100_000n,
args: {
to: "0xMyAddress..." as `0x${string}`, // filter by indexed argument
},
});import { useWatchContractEvent } from "wagmi";
function TransferListener() {
useWatchContractEvent({
address: TOKEN_ADDRESS,
abi: erc20Abi,
eventName: "Transfer",
onLogs: (logs) => {
console.log("New transfers:", logs);
},
});
return <p>Listening for transfers...</p>;
}// ─── ethers.js v6 ───────────────────────────────────────────
import { ethers, AbiCoder } from "ethers";
const coder = AbiCoder.defaultAbiCoder();
// Encode function call
const iface = new ethers.Interface(ERC20_ABI);
const calldata = iface.encodeFunctionData("transfer", ["0xRecipient...", ethers.parseUnits("100", 18)]);
// Decode function result
const result = iface.decodeFunctionResult("balanceOf", "0x...(raw bytes)...");
// Encode/decode raw types
const encoded = coder.encode(["address", "uint256"], ["0x...", 100n]);
const decoded = coder.decode(["address", "uint256"], encoded);
// Parse a transaction's calldata
const parsed = iface.parseTransaction({ data: "0x...", value: 0n });
console.log("Function:", parsed?.name);
console.log("Args:", parsed?.args);
// Get function selector
const selector = iface.getFunction("transfer")!.selector; // "0xa9059cbb"
// ─── viem v2 ────────────────────────────────────────────────
import { encodeFunctionData, decodeFunctionResult, encodeAbiParameters, decodeAbiParameters, toFunctionSelector } from "viem";
// Encode function call
const calldata2 = encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0xRecipient..." as `0x${string}`, parseUnits("100", 18)],
});
// Decode result
const [balance] = decodeFunctionResult({
abi: erc20Abi,
functionName: "balanceOf",
data: "0x...",
});
// Encode raw ABI types
const encoded2 = encodeAbiParameters(
[{ type: "address" }, { type: "uint256" }],
["0x..." as `0x${string}`, 100n]
);
// Function selector
const selector2 = toFunctionSelector("transfer(address,uint256)"); // "0xa9059cbb"Complete pattern for an ERC-20 approve → action flow (e.g., staking, DEX swap):
import { useReadContract, useWriteContract, useWaitForTransactionReceipt, useAccount } from "wagmi";
import { erc20Abi, parseUnits, maxUint256 } from "viem";
const TOKEN = "0xTokenAddress..." as `0x${string}`;
const SPENDER = "0xProtocolAddress..." as `0x${string}`;
const DECIMALS = 18;
function ApproveAndStake({ amount }: { amount: string }) {
const { address } = useAccount();
const parsedAmount = parseUnits(amount, DECIMALS);
// Read current allowance
const { data: allowance } = useReadContract({
address: TOKEN,
abi: erc20Abi,
functionName: "allowance",
args: [address!, SPENDER],
query: { enabled: !!address },
});
const needsApproval = allowance !== undefined && allowance < parsedAmount;
// Approve tx
const { writeContract: approve, data: approveTxHash, isPending: isApproving } = useWriteContract();
const { isSuccess: approveConfirmed } = useWaitForTransactionReceipt({ hash: approveTxHash });
// Stake tx
const { writeContract: stake, data: stakeTxHash, isPending: isStaking } = useWriteContract();
const { isSuccess: stakeConfirmed } = useWaitForTransactionReceipt({ hash: stakeTxHash });
const handleApprove = () => {
approve({
address: TOKEN,
abi: erc20Abi,
functionName: "approve",
args: [SPENDER, maxUint256], // infinite approval (use carefully)
});
};
const handleStake = () => {
stake({
address: SPENDER,
abi: STAKING_ABI,
functionName: "stake",
args: [parsedAmount],
});
};
return (
<div>
{needsApproval ? (
<button onClick={handleApprove} disabled={isApproving}>
{isApproving ? "Approving..." : "Approve Token"}
</button>
) : (
<button onClick={handleStake} disabled={isStaking}>
{isStaking ? "Staking..." : "Stake"}
</button>
)}
{approveConfirmed && <p>Approved! Now stake.</p>}
{stakeConfirmed && <p>Staked successfully!</p>}
</div>
);
}// ─── ethers.js v6 ───────────────────────────────────────────
const nft = new ethers.Contract(NFT_ADDRESS, ERC721_ABI, provider);
// Read NFT metadata
const owner = await nft.ownerOf(1n);
const tokenURI = await nft.tokenURI(1n);
const balance = await nft.balanceOf("0xOwner...");
// Fetch and display metadata from tokenURI (IPFS or HTTP)
const response = await fetch(tokenURI.replace("ipfs://", "https://ipfs.io/ipfs/"));
const metadata = await response.json();
// { name, description, image, attributes }
// Transfer NFT
const signer = await provider.getSigner();
const nftWithSigner = nft.connect(signer);
const tx = await nftWithSigner["safeTransferFrom(address,address,uint256)"](
owner, "0xRecipient...", 1n
);
// ─── wagmi React Hook ───────────────────────────────────────
import { useReadContracts } from "wagmi";
import { erc721Abi } from "viem";
function NFTCard({ tokenId }: { tokenId: bigint }) {
const { data } = useReadContracts({
contracts: [
{ address: NFT_ADDRESS, abi: erc721Abi, functionName: "ownerOf", args: [tokenId] },
{ address: NFT_ADDRESS, abi: erc721Abi, functionName: "tokenURI", args: [tokenId] },
],
});
const owner = data?.[0].result as `0x${string}`;
const tokenURI = data?.[1].result as string;
// Resolve IPFS URIs to HTTP gateways
const imageURL = tokenURI?.replace("ipfs://", "https://ipfs.io/ipfs/");
return (
<div>
<p>Token ID: {tokenId.toString()}</p>
<p>Owner: {owner}</p>
<img src={imageURL} alt={`NFT #${tokenId}`} />
</div>
);
}// ─── ethers.js v6 ───────────────────────────────────────────
const multi = new ethers.Contract(ERC1155_ADDRESS, ERC1155_ABI, provider);
// Read balances
const balance = await multi.balanceOf("0xOwner...", 1n);
// Read multiple balances at once (balanceOfBatch)
const owners = ["0xAlice...", "0xBob...", "0xCharlie..."];
const ids = [1n, 2n, 3n];
const balances = await multi.balanceOfBatch(owners, ids);
// Transfer single token type
const signer = await provider.getSigner();
const tx = await multi.connect(signer).safeTransferFrom(
"0xFrom...",
"0xTo...",
1n, // token id
10n, // amount
"0x" // data
);
// Batch transfer
const tx2 = await multi.connect(signer).safeBatchTransferFrom(
"0xFrom...",
"0xTo...",
[1n, 2n, 3n], // ids
[10n, 20n, 5n], // amounts
"0x"
);Integrate account abstraction so users pay no gas (or pay in ERC-20):
// Using permissionless.js (built on viem) — simplest ERC-4337 integration
// npm install permissionless
import {
createSmartAccountClient,
ENTRYPOINT_ADDRESS_V07,
} from "permissionless";
import { signerToSimpleSmartAccount } from "permissionless/accounts";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
const publicClient = createPublicClient({ chain: sepolia, transport: http() });
// Pimlico = bundler + paymaster service
const pimlicoClient = createPimlicoClient({
transport: http(`https://api.pimlico.io/v2/sepolia/rpc?apikey=${PIMLICO_API_KEY}`),
entryPoint: ENTRYPOINT_ADDRESS_V07,
});
// Create smart account from an EOA signer
const smartAccount = await signerToSimpleSmartAccount(publicClient, {
signer: viemSigner, // e.g., from wagmi's useWalletClient
entryPoint: ENTRYPOINT_ADDRESS_V07,
factoryAddress: "0x...",
});
const smartAccountClient = createSmartAccountClient({
account: smartAccount,
chain: sepolia,
bundlerTransport: http(`https://api.pimlico.io/v2/sepolia/rpc?apikey=${PIMLICO_API_KEY}`),
middleware: {
// Paymaster: sponsor gas for the user
sponsorUserOperation: pimlicoClient.sponsorUserOperation,
},
});
// Send a gasless transaction — user pays no ETH
const txHash = await smartAccountClient.sendTransaction({
to: TOKEN_ADDRESS as `0x${string}`,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0xRecipient...", parseUnits("100", 18)],
}),
});Read dozens of contract values in a single RPC call using Multicall3:
// ─── ethers.js v6 ───────────────────────────────────────────
import { ethers } from "ethers";
const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
const MULTICALL3_ABI = [
"function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) returns (tuple(bool success, bytes returnData)[] results)"
];
const multi = new ethers.Contract(MULTICALL3, MULTICALL3_ABI, provider);
const iface = new ethers.Interface(erc20Abi);
const targets = ["0xTokenA...", "0xTokenB...", "0xTokenC..."];
const calls = targets.map((addr) => ({
target: addr,
allowFailure: true,
callData: iface.encodeFunctionData("totalSupply", []),
}));
const results = await multi.aggregate3(calls);
results.forEach(({ success, returnData }, i) => {
if (success) {
const [supply] = iface.decodeFunctionResult("totalSupply", returnData);
console.log(`${targets[i]}: ${ethers.formatEther(supply)}`);
}
});
// ─── viem v2 (built-in multicall) ───────────────────────────
const results2 = await publicClient.multicall({
contracts: targets.map((address) => ({
address: address as `0x${string}`,
abi: erc20Abi,
functionName: "totalSupply" as const,
})),
allowFailure: true,
});
// ─── wagmi React Hook ───────────────────────────────────────
const { data } = useReadContracts({
contracts: tokens.map((addr) => ({
address: addr,
abi: erc20Abi,
functionName: "balanceOf",
args: [userAddress],
})),
});// ─── ethers.js v6 ───────────────────────────────────────────
try {
await token.transfer("0x...", amount);
} catch (err: unknown) {
if (err instanceof ethers.ContractTransactionResponse) {
// Transaction sent but reverted
}
if (ethers.isCallException(err)) {
const iface = new ethers.Interface(ERC20_ABI);
try {
const decoded = iface.parseError(err.data ?? "0x");
console.log("Custom error:", decoded?.name);
console.log("Args:", decoded?.args);
} catch {
console.log("Unknown error:", err.data);
}
// Check specific custom error
if (err.data?.startsWith("0xf4d678b8")) { // InsufficientBalance selector
console.log("Insufficient balance");
}
}
if ((err as { code?: string }).code === "ACTION_REJECTED") {
console.log("User rejected transaction");
}
}
// ─── viem v2 ────────────────────────────────────────────────
import { decodeErrorResult, BaseError, ContractFunctionRevertedError, UserRejectedRequestError } from "viem";
try {
await walletClient.writeContract({ ... });
} catch (err) {
if (err instanceof BaseError) {
const revertError = err.walk((e) => e instanceof ContractFunctionRevertedError);
if (revertError instanceof ContractFunctionRevertedError) {
const errorName = revertError.data?.errorName;
console.log("Reverted with:", errorName);
// "InsufficientBalance", "Unauthorized", etc.
}
if (err.walk((e) => e instanceof UserRejectedRequestError)) {
console.log("User rejected");
}
}
}function getReadableError(err: unknown): string {
if (err instanceof Error) {
// viem custom errors
if (err.message.includes("InsufficientBalance")) return "Insufficient balance";
if (err.message.includes("Unauthorized")) return "Not authorized";
if (err.message.includes("User rejected")) return "Transaction cancelled";
if (err.message.includes("insufficient funds")) return "Not enough ETH for gas";
if (err.message.includes("nonce too low")) return "Nonce conflict, please retry";
if (err.message.includes("replacement fee too low")) return "Increase gas price to replace";
}
return "Transaction failed. Please try again.";
}// ─── ethers.js v6 — Plain message ───────────────────────────
const signer = await provider.getSigner();
const message = "Sign in to MyApp";
const signature = await signer.signMessage(message);
// Verify on frontend
const recovered = ethers.verifyMessage(message, signature);
// recovered === signer address
// ─── ethers.js v6 — EIP-712 typed data ──────────────────────
const domain = {
name: "MyProtocol",
version: "1",
chainId: 1,
verifyingContract: "0xContractAddress...",
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline",type: "uint256" },
],
};
const value = {
owner: await signer.getAddress(),
spender: "0xSpender...",
value: ethers.parseUnits("100", 18),
nonce: await token.nonces(await signer.getAddress()),
deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour
};
const sig = await signer.signTypedData(domain, types, value);
const { v, r, s } = ethers.Signature.from(sig);
// Submit permit — user never needs ETH to approve!
await token.permit(value.owner, value.spender, value.value, value.deadline, v, r, s);
// ─── viem v2 — EIP-712 ──────────────────────────────────────
import { signTypedData } from "viem/actions";
const signature2 = await walletClient.signTypedData({
domain,
types,
primaryType: "Permit",
message: value,
});Authenticate users with their wallet instead of passwords:
// npm install siwe viem
import { SiweMessage, generateNonce } from "siwe";
// 1. Backend generates nonce and stores it in session
// GET /api/auth/nonce → returns random nonce
// 2. Frontend builds and signs the SIWE message
async function signIn(address: string, chainId: number) {
const nonce = await fetch("/api/auth/nonce").then((r) => r.text());
const message = new SiweMessage({
domain: window.location.host,
address,
statement: "Sign in to MyApp with your Ethereum wallet.",
uri: window.location.origin,
version: "1",
chainId,
nonce,
});
const prepared = message.prepareMessage();
const signature = await walletClient.signMessage({ message: prepared, account: address as `0x${string}` });
// 3. Backend verifies
const result = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: prepared, signature }),
});
return result.json(); // { ok: true, address }
}
// 4. Backend verification (Node.js)
import { SiweMessage } from "siwe";
async function verifySiwe(message: string, signature: string, expectedNonce: string) {
const siweMessage = new SiweMessage(message);
const result = await siweMessage.verify({ signature, nonce: expectedNonce });
if (!result.success) throw new Error("Invalid SIWE signature");
return result.data.address; // verified Ethereum address
}Simulate before sending to catch reverts early and protect UX:
// ─── ethers.js v6 — staticCall ──────────────────────────────
// staticCall runs the function without broadcasting — reverts if it would fail
try {
await token.transfer.staticCall("0xRecipient...", amount, {
from: userAddress,
});
console.log("Transaction will succeed — safe to send");
} catch (err) {
console.log("Would revert:", err);
}
// ─── viem v2 — simulateContract ─────────────────────────────
import { simulateContract } from "viem/actions";
try {
const { request } = await publicClient.simulateContract({
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "transfer",
args: ["0xRecipient..." as `0x${string}`, parseUnits("100", 18)],
account: userAddress,
});
// Simulation succeeded — now send the real transaction
const txHash = await walletClient.writeContract(request);
} catch (err) {
// Caught before any gas is spent
console.log("Simulation failed:", err);
}
// ─── wagmi React Hook ───────────────────────────────────────
import { useSimulateContract, useWriteContract } from "wagmi";
function SafeTransferButton() {
const { data: simulation, error: simError } = useSimulateContract({
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "transfer",
args: [RECIPIENT, parseUnits("100", 18)],
});
const { writeContract, isPending } = useWriteContract();
return (
<button
disabled={!simulation?.request || isPending || !!simError}
onClick={() => simulation?.request && writeContract(simulation.request)}
>
{simError ? `Will fail: ${simError.message}` : "Transfer"}
</button>
);
}// ─── ethers.js v6 ───────────────────────────────────────────
const feeData = await provider.getFeeData();
console.log("Base fee:", ethers.formatUnits(feeData.lastBaseFeePerGas!, "gwei"), "gwei");
console.log("Max fee:", ethers.formatUnits(feeData.maxFeePerGas!, "gwei"), "gwei");
console.log("Priority:", ethers.formatUnits(feeData.maxPriorityFeePerGas!, "gwei"), "gwei");
// Estimate gas for a specific call
const gasEstimate = await token.transfer.estimateGas("0xRecipient...", amount, {
from: userAddress,
});
// Add 20% buffer to avoid out-of-gas
const gasLimit = (gasEstimate * 120n) / 100n;
const tx = await token.transfer("0xRecipient...", amount, {
gasLimit,
maxFeePerGas: feeData.maxFeePerGas!,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas!,
});
// ─── viem v2 ────────────────────────────────────────────────
const gasEstimate2 = await publicClient.estimateContractGas({
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "transfer",
args: [RECIPIENT, parseUnits("100", 18)],
account: userAddress,
});
const fees = await publicClient.estimateFeesPerGas();
// fees.maxFeePerGas, fees.maxPriorityFeePerGas
// Cost in ETH
const costWei = gasEstimate2 * fees.maxFeePerGas!;
const costEth = formatEther(costWei);
console.log("Estimated cost:", costEth, "ETH");# Start Anvil with a mainnet fork
anvil --fork-url $MAINNET_RPC_URL --fork-block-number 19500000
# Your frontend connects to localhost:8545
# All mainnet contracts are available// In tests — use Anvil's local RPC
const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545");
// Impersonate a whale to test with real tokens
await provider.send("anvil_impersonateAccount", [WHALE_ADDRESS]);
const whale = await provider.getSigner(WHALE_ADDRESS);
const token = new ethers.Contract(USDC_ADDRESS, erc20Abi, whale);
await token.transfer(testUser, parseUnits("10000", 6));
await provider.send("anvil_stopImpersonatingAccount", [WHALE_ADDRESS]);// npm install -D @wagmi/test vitest
import { renderHook, waitFor } from "@testing-library/react";
import { useReadContract } from "wagmi";
import { createWrapper } from "@wagmi/test"; // wraps with WagmiProvider
test("reads ERC20 balance", async () => {
const { result } = renderHook(
() => useReadContract({
address: TOKEN_ADDRESS,
abi: erc20Abi,
functionName: "balanceOf",
args: [TEST_ADDRESS],
}),
{ wrapper: createWrapper({ config: testConfig }) }
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toBe(100n * 10n ** 18n);
});NEVER expose private keys in frontend code — use wallet providers only.
ALWAYS validate chain ID before sending transactions:
const { chainId } = useAccount();
if (chainId !== mainnet.id) return <WrongNetworkBanner />;
SIMULATE before sending to avoid user-facing revert errors.
VALIDATE inputs client-side before encoding:
- amount > 0
- recipient is a valid address (isAddress() from viem)
- deadline has not passed
DISPLAY human-readable amounts — never raw wei to users.
HANDLE slippage on DEX interactions — add minAmountOut parameter.
AVOID infinite approvals for high-value tokens — use exact amounts.
PROTECT against phishing:
- Never ask users to sign arbitrary messages with personal data
- Always show the exact action being signed in the UI
USE ENS / address labels to reduce copy-paste errors.
RATE LIMIT RPC calls — cache reads, debounce user inputs.
NEVER store sensitive data (private keys, mnemonics) in localStorage.
Use session-only storage for wallet state.
DISPLAY transaction status clearly:
- Pending (in mempool)
- Confirming (1 of N blocks)
- Confirmed (success)
- Failed (reverted)
| Tool | Purpose | Install |
|---|---|---|
| viem | Low-level EVM client (TypeScript) | npm i viem |
| wagmi | React hooks for Ethereum | npm i wagmi viem |
| ethers.js v6 | Full-featured Ethereum library | npm i ethers |
| permissionless | ERC-4337 client (built on viem) | npm i permissionless |
| siwe | Sign-In With Ethereum | npm i siwe |
| @tanstack/react-query | Data fetching for wagmi | npm i @tanstack/react-query |
| RainbowKit | Wallet connection UI (wagmi) | npm i @rainbow-me/rainbowkit |
| ConnectKit | Wallet connection UI (wagmi) | npm i connectkit |
| Web3Modal | WalletConnect modal | npm i @web3modal/wagmi |
| Foundry / Anvil | Local EVM node for testing | curl -L https://foundry.paradigm.xyz | bash |
# Compile and generate ABI
forge build
# ABI is at out/ContractName.sol/ContractName.json
# Extract just the ABI:
cat out/Standards.sol/ERC20Token.json | jq '.abi' > src/abis/ERC20Token.json
# Generate TypeScript types from ABI (wagmi CLI)
# npm install -D @wagmi/cli
# wagmi.config.ts:
# import { defineConfig } from "@wagmi/cli";
# import { foundry } from "@wagmi/cli/plugins";
# export default defineConfig({
# out: "src/generated.ts",
# plugins: [foundry({ project: "../" })],
# });
# npx wagmi generate# Read contract state
cast call $TOKEN "balanceOf(address)(uint256)" $USER --rpc-url $RPC
# Decode a transaction's calldata
cast calldata-decode "transfer(address,uint256)" 0xa9059cbb...
# Decode a log/event
cast decode-event "Transfer(address indexed,address indexed,uint256)" <topics> <data>
# Get current gas price
cast gas-price --rpc-url $RPC
# Convert units
cast to-wei 1.5 ether
cast from-wei 1500000000000000000
# Check if address is a contract
cast code $ADDRESS --rpc-url $RPC | wc -c # > 2 = contract