viem + wagmi
The contract-interaction layer for every Ekinoxis dApp frontend.
viem in 8 projects, wagmi in 6.
Docs: https://viem.sh · https://wagmi.sh — or ask the context7 MCP server, since
viem's API changed meaningfully between v1 and v2.
The split
- viem — the primitive. Use directly in server code, scripts, route handlers.
- wagmi — React hooks over viem, with caching via TanStack Query. Use in components.
When Privy is the wallet, wagmi comes from @privy-io/wagmi, not wagmi —
see ../ekx-privy/SKILL.md.
Config
// lib/chain.ts
import { base, baseSepolia } from "viem/chains";
// One chain at a time, chosen by env var — our standard pattern.
// No chain switcher in the UI; it is a support burden and a source of wrong-network txs.
export const CHAIN = process.env.NEXT_PUBLIC_CHAIN === "mainnet" ? base : baseSepolia;
export const RPC_URL =
process.env.NEXT_PUBLIC_CHAIN === "mainnet"
? process.env.NEXT_PUBLIC_BASE_MAINNET_RPC_URL!
: process.env.NEXT_PUBLIC_BASE_SEPOLIA_RPC_URL!;
// lib/viem.ts — server-side reads
import { createPublicClient, http } from "viem";
export const publicClient = createPublicClient({
chain: CHAIN,
transport: http(RPC_URL),
});
Always pass an explicit RPC URL. http() with no argument uses the chain's public
endpoint, which rate-limits under real traffic and produces intermittent,
hard-to-reproduce read failures in production.
Decimals — read this before writing any amount
import { parseUnits, formatUnits } from "viem";
parseUnits("10.50", 6) // 10500000n — USDC. CORRECT.
parseEther("10.50") // 10500000000000000000n — 10.5 QUINTILLION units of USDC. WRONG.
formatUnits(10500000n, 6) // "10.5"
USDC is 6 decimals. ETH and most ERC-20s are 18. Mixing them up is our single most frequent onchain bug. Read the decimals from the contract if you are unsure:
const decimals = await publicClient.readContract({ address: USDC, abi: erc20Abi, functionName: "decimals" });
Reading
import { useReadContract } from "wagmi";
const { data: bid, isLoading } = useReadContract({
address: auctionAddress,
abi: auctionAbi,
functionName: "highestBid",
query: { refetchInterval: 5_000 }, // live auctions need polling
});
Batch reads with useReadContracts — one multicall instead of N round-trips. Base
supports multicall3; viem uses it automatically when batch: { multicall: true } is
set on the transport.
Writing — the approve-then-spend dance
Every USDC payment in our apps is two transactions. Users do not expect this; the UI must say so.
const { writeContractAsync } = useWriteContract();
async function placeBid(amount: string) {
const value = parseUnits(amount, 6);
// 1. Check existing allowance — skip the approve if it is already enough
const allowance = await publicClient.readContract({
address: USDC, abi: erc20Abi, functionName: "allowance",
args: [userAddress, auctionAddress],
});
if (allowance < value) {
const approveHash = await writeContractAsync({
address: USDC, abi: erc20Abi, functionName: "approve",
args: [auctionAddress, value],
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });
}
// 2. The actual call
const hash = await writeContractAsync({
address: auctionAddress, abi: auctionAbi, functionName: "bid", args: [value],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status === "reverted") throw new Error("La transacción falló");
return receipt;
}
Do not skip waitForTransactionReceipt between the two. Sending bid before the
approve is mined reverts, and the user sees a confusing failure on the second popup.
Check receipt.status. A mined transaction can still have reverted; viem does
not throw for that.
Simulate before writing
const { request } = await publicClient.simulateContract({
address, abi, functionName: "bid", args: [value], account: userAddress,
});
const hash = await walletClient.writeContract(request);
Simulation surfaces the revert reason before the user pays gas and before the wallet popup. Worth it on any call that can fail for business reasons (auction ended, bid too low, not whitelisted).
Events
const logs = await publicClient.getLogs({
address: auctionAddress,
event: parseAbiItem("event BidPlaced(address indexed bidder, uint256 amount)"),
fromBlock: deployBlock,
toBlock: "latest",
});
Most RPCs cap the block range (Alchemy: 10k blocks). For anything historical, page the
range — or use a subgraph, see ../ekx-thegraph/SKILL.md.
Gotchas
parseEtheron USDC. The bug. AlwaysparseUnits(x, 6).- Public RPC in production. Intermittent failures under load. Use Alchemy.
- Wrong chain. Call
switchChainbefore writes; Privy embedded wallets do not switch on their own. receipt.status === "reverted"on a mined tx — check it.biginteverywhere. No+with a number;JSON.stringifythrows on bigint. Serialize with.toString().- ABI must be
as const(or generated) for viem's type inference to work. A plainany[]ABI compiles but gives you no argument checking. - Block-range caps on
getLogs.