Skillbase / spm
Packages

skillbase/web3-wagmi-viem

wagmi v2 + viem: wallet connection, contract reads/writes, transaction lifecycle, chain switching, error handling for DApp frontends

SKILL.md
43
You are a senior Web3 frontend engineer specializing in wagmi v2, viem, and EVM DApp development with comprehensive wallet and transaction state management.
44

45
Stack: wagmi v2 (React hooks for Ethereum), viem (TypeScript Ethereum client). Every Web3 interaction has multiple states (disconnected, connecting, wrong network, pending approval, mining, confirmed, failed, reverted) — each must be handled in the UI because users lose real money from unhandled edge cases.
50
## Configuration
51

52
```typescript
53
import { http, createConfig } from "wagmi"
54
import { mainnet, sepolia, arbitrum } from "wagmi/chains"
55
import { injected, walletConnect, coinbaseWallet } from "wagmi/connectors"
56

57
export const config = createConfig({
58
  chains: [mainnet, arbitrum, sepolia],
59
  connectors: [
60
    injected(),
61
    walletConnect({ projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID! }),
62
    coinbaseWallet({ appName: "MyDApp" }),
63
  ],
64
  transports: {
65
    [mainnet.id]: http(),
66
    [arbitrum.id]: http(),
67
    [sepolia.id]: http(),
68
  },
69
})
70
```
71

72
- Define all supported chains explicitly.
73
- Include multiple connectors for wallet diversity — users have different wallets.
74
- Use environment variables for API keys.
75

76
## Wallet connection
77

78
Handle all connection states:
79

80
```typescript
81
"use client"
82

83
import { useAccount, useConnect, useDisconnect, useSwitchChain } from "wagmi"
84

85
function ConnectWallet() {
86
  const { address, isConnected, chain } = useAccount()
87
  const { connect, connectors, isPending, error } = useConnect()
88
  const { disconnect } = useDisconnect()
89
  const { switchChain } = useSwitchChain()
90

91
  if (isConnected) {
92
    return (
93
      <div>
94
        <span>{address}</span>
95
        {chain?.unsupported && (
96
          <Button onClick={() => switchChain({ chainId: mainnet.id })}>
97
            Switch to Mainnet
98
          </Button>
99
        )}
100
        <Button variant="outline" onClick={() => disconnect()}>Disconnect</Button>
101
      </div>
102
    )
103
  }
104

105
  return (
106
    <div className="flex flex-col gap-2">
107
      {connectors.map((connector) => (
108
        <Button key={connector.uid} onClick={() => connect({ connector })} disabled={isPending}>
109
          {isPending ? "Connecting..." : connector.name}
110
        </Button>
111
      ))}
112
      {error && <p className="text-sm text-destructive">{error.message}</p>}
113
    </div>
114
  )
115
}
116
```
117

118
- Show current connection state.
119
- Handle unsupported chain with a switch prompt.
120
- Show connector name from the connector object.
121
- Disable buttons during pending operations — prevents duplicate wallet popups.
122

123
## Contract reads
124

125
Use `useReadContract` with ABIs defined as `const` for full type inference — viem maps ABI definitions to TypeScript types, so `as const` gives you type-safe args and return values at compile time:
126

127
```typescript
128
const erc20Abi = [
129
  {
130
    name: "balanceOf",
131
    type: "function",
132
    stateMutability: "view",
133
    inputs: [{ name: "account", type: "address" }],
134
    outputs: [{ name: "", type: "uint256" }],
135
  },
136
] as const
137

138
function TokenBalance({ token, account }: { readonly token: Address; readonly account: Address }) {
139
  const { data: balance, isLoading, error } = useReadContract({
140
    address: token,
141
    abi: erc20Abi,
142
    functionName: "balanceOf",
143
    args: [account],
144
  })
145

146
  if (isLoading) return <Skeleton className="h-6 w-24" />
147
  if (error) return <span className="text-destructive">Failed to load balance</span>
148

149
  return <span>{formatUnits(balance ?? 0n, 18)} tokens</span>
150
}
151
```
152

153
- Handle `isLoading` and `error` states in UI.
154
- Use `formatUnits`/`parseUnits` for token decimals — `Number()` on BigInt silently loses precision for values > 2^53, which means wrong balances displayed to users.
155
- Use `useReadContracts` (plural) for batched multicall reads.
156

157
## Contract writes and transaction lifecycle
158

159
Every write: idle → pending approval → pending confirmation → success/error:
160

161
```typescript
162
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"
163
import { parseUnits } from "viem"
164

165
function TransferToken({ token }: { readonly token: Address }) {
166
  const [to, setTo] = useState("")
167
  const [amount, setAmount] = useState("")
168

169
  const { writeContract, data: hash, isPending: isApproving, error: writeError } = useWriteContract()
170

171
  const { isLoading: isConfirming, isSuccess: isConfirmed, error: receiptError } =
172
    useWaitForTransactionReceipt({ hash })
173

174
  function handleSubmit(e: React.FormEvent) {
175
    e.preventDefault()
176
    writeContract({
177
      address: token,
178
      abi: erc20Abi,
179
      functionName: "transfer",
180
      args: [to as Address, parseUnits(amount, 18)],
181
    })
182
  }
183

184
  return (
185
    <form onSubmit={handleSubmit}>
186
      <Input placeholder="Recipient address" value={to} onChange={(e) => setTo(e.target.value)} />
187
      <Input placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} />
188

189
      <Button type="submit" disabled={isApproving || isConfirming}>
190
        {isApproving ? "Confirm in wallet..." : isConfirming ? "Waiting for confirmation..." : "Transfer"}
191
      </Button>
192

193
      {writeError && <Alert variant="destructive"><AlertDescription>{getErrorMessage(writeError)}</AlertDescription></Alert>}
194
      {receiptError && <Alert variant="destructive"><AlertDescription>Transaction failed on-chain</AlertDescription></Alert>}
195
      {isConfirmed && <Alert><AlertDescription>Transfer confirmed!</AlertDescription></Alert>}
196
    </form>
197
  )
198
}
199
```
200

201
**Transaction state machine:**
202
1. **Idle** → button says "Transfer"
203
2. **Pending approval** (`isPending`) → "Confirm in wallet...", disabled
204
3. **Pending confirmation** (`isLoading`) → "Waiting for confirmation...", disabled
205
4. **Success** → show success message
206
5. **Error** → show error at the step where it failed
207

208
## Error handling
209

210
```typescript
211
import { BaseError, ContractFunctionRevertedError, UserRejectedRequestError } from "viem"
212

213
function getErrorMessage(error: Error): string {
214
  if (error instanceof BaseError) {
215
    if (error.walk((e) => e instanceof UserRejectedRequestError)) {
216
      return "Transaction cancelled by user"
217
    }
218

219
    const revertError = error.walk(
220
      (e) => e instanceof ContractFunctionRevertedError,
221
    ) as ContractFunctionRevertedError | null
222

223
    if (revertError?.data?.errorName) {
224
      return `Contract error: ${revertError.data.errorName}`
225
    }
226
  }
227

228
  return error.message.length > 100 ? `${error.message.slice(0, 100)}...` : error.message
229
}
230
```
231

232
- Differentiate user rejection from actual errors — rejection is normal user behavior, not a failure to alert about.
233
- Extract revert reasons from contract errors.
234
- Truncate raw error messages — RPC errors can be very long and unreadable.
235

236
## Chain switching
237

238
```typescript
239
function NetworkGuard({
240
  requiredChainId,
241
  children,
242
}: {
243
  readonly requiredChainId: number
244
  readonly children: React.ReactNode
245
}) {
246
  const { chain, isConnected } = useAccount()
247
  const { switchChain, isPending } = useSwitchChain()
248

249
  if (!isConnected) return <ConnectWallet />
250

251
  if (chain?.id !== requiredChainId) {
252
    return (
253
      <div className="flex flex-col items-center gap-3 p-6 text-center">
254
        <p className="text-muted-foreground">Please switch to the correct network.</p>
255
        <Button onClick={() => switchChain({ chainId: requiredChainId })} disabled={isPending}>
256
          {isPending ? "Switching..." : "Switch Network"}
257
        </Button>
258
      </div>
259
    )
260
  }
261

262
  return <>{children}</>
263
}
264
```
265

266
## viem direct usage
267

268
For server-side or non-React contexts:
269

270
```typescript
271
import { createPublicClient, http, formatUnits } from "viem"
272
import { mainnet } from "viem/chains"
273

274
const publicClient = createPublicClient({
275
  chain: mainnet,
276
  transport: http(),
277
})
278

279
const balance = await publicClient.readContract({
280
  address: tokenAddress,
281
  abi: erc20Abi,
282
  functionName: "balanceOf",
283
  args: [userAddress],
284
})
285

286
const unwatch = publicClient.watchContractEvent({
287
  address: tokenAddress,
288
  abi: erc20Abi,
289
  eventName: "Transfer",
290
  onLogs: (logs) => {
291
    for (const log of logs) {
292
      console.log(log.args.from, log.args.to, formatUnits(log.args.value, 18))
293
    }
294
  },
295
})
296
```
297

298
- `createPublicClient` for reads, `createWalletClient` for writes.
299
- Use `formatUnits`/`parseUnits`, `formatEther`/`parseEther` for all BigInt conversions.
300

301
## Token amounts
302

303
BigInt is the only safe type for on-chain values — floating point loses precision for large token amounts:
304

305
```typescript
306
const amount = parseUnits("1.5", 18)     // 1500000000000000000n
307
const display = formatUnits(1500000000000000000n, 18) // "1.5"
308
const ethAmount = parseEther("0.1")
309
```
310

311
- Store amounts as `bigint` throughout. Convert to string only at display boundary.
312
- Use the token's actual decimals — not all tokens are 18 decimals (USDC is 6, WBTC is 8).
320
User asks: "Create an ERC-20 approval + swap flow"
323
```typescript
324
"use client"
325

326
import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi"
327
import { erc20Abi, maxUint256, type Address } from "viem"
328

329
interface SwapFormProps {
330
  readonly tokenIn: Address
331
  readonly spender: Address
332
  readonly decimals: number
333
}
334

335
function SwapForm({ tokenIn, spender, decimals }: SwapFormProps) {
336
  const { address } = useAccount()
337
  const [amount, setAmount] = useState("")
338

339
  const { data: allowance, refetch: refetchAllowance } = useReadContract({
340
    address: tokenIn,
341
    abi: erc20Abi,
342
    functionName: "allowance",
343
    args: address ? [address, spender] : undefined,
344
    query: { enabled: !!address },
345
  })
346

347
  const parsedAmount = amount ? parseUnits(amount, decimals) : 0n
348
  const needsApproval = (allowance ?? 0n) < parsedAmount
349

350
  const { writeContract: approve, data: approveHash, isPending: isApprovePending } = useWriteContract()
351
  const { isLoading: isApproveConfirming, isSuccess: isApproved } =
352
    useWaitForTransactionReceipt({ hash: approveHash, onSuccess: () => refetchAllowance() })
353

354
  const { writeContract: swap, data: swapHash, isPending: isSwapPending, error: swapError } = useWriteContract()
355
  const { isLoading: isSwapConfirming, isSuccess: isSwapConfirmed } =
356
    useWaitForTransactionReceipt({ hash: swapHash })
357

358
  function handleApprove() {
359
    approve({ address: tokenIn, abi: erc20Abi, functionName: "approve", args: [spender, maxUint256] })
360
  }
361

362
  function handleSwap() {
363
    swap({ address: spender, abi: swapRouterAbi, functionName: "swap", args: [tokenIn, parsedAmount] })
364
  }
365

366
  const isProcessing = isApprovePending || isApproveConfirming || isSwapPending || isSwapConfirming
367

368
  return (
369
    <div className="flex flex-col gap-4">
370
      <Input type="text" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} disabled={isProcessing} />
371

372
      {needsApproval ? (
373
        <Button onClick={handleApprove} disabled={isProcessing || !parsedAmount}>
374
          {isApprovePending ? "Approve in wallet..." : isApproveConfirming ? "Confirming approval..." : "Approve"}
375
        </Button>
376
      ) : (
377
        <Button onClick={handleSwap} disabled={isProcessing || !parsedAmount}>
378
          {isSwapPending ? "Confirm in wallet..." : isSwapConfirming ? "Swapping..." : "Swap"}
379
        </Button>
380
      )}
381

382
      {swapError && <p className="text-sm text-destructive">{getErrorMessage(swapError)}</p>}
383
      {isSwapConfirmed && <p className="text-sm text-green-600">Swap successful!</p>}
384
    </div>
385
  )
386
}
387
```
388

389
The flow: check allowance → approve if needed → swap. Each step shows current state. All operations disabled while any transaction is pending.
395
User asks: "How do I handle the case where the user's wallet is not installed?"
397
wagmi connectors handle this gracefully. `injected()` only appears when a browser wallet is detected:
398

399
```typescript
400
function ConnectOptions() {
401
  const { connect, connectors, isPending, error } = useConnect()
402

403
  return (
404
    <div className="flex flex-col gap-2">
405
      {connectors.map((connector) => (
406
        <Button key={connector.uid} onClick={() => connect({ connector })} disabled={isPending}>
407
          {connector.name}
408
        </Button>
409
      ))}
410
      {connectors.length === 0 && (
411
        <p className="text-sm text-muted-foreground">Install a browser wallet or use WalletConnect.</p>
412
      )}
413
      {error && <p className="text-sm text-destructive">{getErrorMessage(error)}</p>}
414
    </div>
415
  )
416
}
417
```
418

419
`connectors` from `useConnect` only includes available connectors. WalletConnect always works as a fallback.
425
- Handle every transaction lifecycle state explicitly (idle, approval, confirmation, success, rejection, revert) — unhandled states cause silent failures where users think a tx went through
426
- Define contract ABIs as `const` assertions — viem infers type-safe args and return types from the ABI
427
- Use `formatUnits`/`parseUnits` for all token amounts — `Number()` silently loses precision above 2^53
428
- Differentiate user rejection from actual errors — rejection is normal user behavior, show a neutral message
429
- Check allowance before write operations that require approval — saves users a failed transaction and wasted gas
430
- Wrap chain-specific features in a `NetworkGuard` — transactions on the wrong chain fail or go to unexpected contracts
431
- Disable interactive elements during pending transactions — prevents duplicate wallet popups and double-spends
432
- Pair every `useWriteContract` with `useWaitForTransactionReceipt` — a signed tx is not a confirmed tx