Post 06

Wallet-Connect UX: Designing for the Unhappy Path

Connect, sign, done — that is the demo. Real Web3 UX is rejected signatures, wrong networks, stuck transactions, and RPCs that go quiet. Design the failure states first and the product feels trustworthy. Here is the code for each one.

Jul 22, 2025/12 min readWeb3
ShareY
Wallet-Connect UX: Designing for the Unhappy Path

The reason so many Web3 interfaces feel janky is that they were built happy-path first. Connect wallet, sign, success screen — ship it. Then real users arrive with a wallet on the wrong network, a pending transaction from yesterday, a hardware wallet that times out, and an RPC endpoint that is rate-limiting. None of that was designed.

Design the unhappy path first. Here is the map, with viem and wagmi.

Connection is a state machine, not a button

StateWhat the user needs to see
DisconnectedClear CTA, list of supported wallets, no jargon
ConnectingWhich wallet, a way to cancel, “check your wallet” prompt
Connected, wrong networkA button that switches, not just a warning
Connected, right networkTruncated address, balance, disconnect — quiet, not celebratory
Account changed mid-sessionDetect it, update the UI, no reload
Connection rejected“You declined — try again”, not an error toast
components/connect-button.tsx
1"use client";2import { useAccount, useConnect, useDisconnect, useSwitchChain } from "wagmi";3import { mainnet } from "wagmi/chains";4 5export function ConnectButton() {6  const { address, chainId, isConnected } = useAccount();7  const { connectors, connect, status, error } = useConnect();8  const { disconnect } = useDisconnect();9  const { switchChain } = useSwitchChain();10 11  if (!isConnected) {12    return (13      <div>14        {connectors.map((c) => (15          <button key={c.uid} onClick={() => connect({ connector: c })}16                  disabled={status === "pending"}>17            {status === "pending" ? "Check your wallet…" : c.name}18          </button>19        ))}20        {error?.name === "UserRejectedRequestError" && (21          <p>You declined the request — no problem, try again.</p>22        )}23      </div>24    );25  }26 27  if (chainId !== mainnet.id) {28    return (29      <button onClick={() => switchChain({ chainId: mainnet.id })}>30        Switch to Ethereum31      </button>32    );33  }34 35  return (36    <button onClick={() => disconnect()}>37      {address!.slice(0, 6)}…{address!.slice(-4)}38    </button>39  );40}
Watch out

The account-change and chain-change listeners are the ones teams forget. wagmi handles them for you if you use its hooks — but if you drop to a raw provider, you must wire provider.on("accountsChanged", …) and "chainChanged" yourself, or users see stale balances after switching accounts in MetaMask.

Simulate before you ask for a signature

If a contract call will revert, find out before the user pays gas and waits. simulateContract runs the call against current state and either returns a prepared request or throws with the revert reason.

lib/mint.ts
1import { simulateContract, writeContract, waitForTransactionReceipt } from "@wagmi/core";2import { config } from "@/lib/wagmi";3import { abi } from "@/lib/abi";4 5export async function mint(quantity: bigint) {6  // 1. simulate — throws with a human revert reason if it would fail7  const { request } = await simulateContract(config, {8    address: CONTRACT, abi, functionName: "mint", args: [quantity],9  });10 11  // 2. send — this is the only wallet prompt12  const hash = await writeContract(config, request);13 14  // 3. wait — with a timeout so the UI is not stuck forever15  const receipt = await waitForTransactionReceipt(config, {16    hash, timeout: 90_000,17  });18 19  if (receipt.status === "reverted") {20    throw new Error("The transaction reverted on-chain.");21  }22  return receipt;23}

Every transaction has a lifecycle

the transaction status UI
1type TxState =2  | { kind: "idle" }3  | { kind: "simulating" }4  | { kind: "awaiting-signature" }5  | { kind: "pending"; hash: `0x${string}` }6  | { kind: "success"; hash: `0x${string}` }7  | { kind: "error"; message: string };8 9function TxStatus({ state }: { state: TxState }) {10  switch (state.kind) {11    case "simulating":         return <p>Checking the transaction…</p>;12    case "awaiting-signature": return <p>Confirm in your wallet</p>;13    case "pending":14      return (15        <p>16          Submitted.{" "}17          <a href={`https://etherscan.io/tx/${state.hash}`} target="_blank">18            Track on Etherscan19          </a>. You can safely leave this page.20        </p>21      );22    case "success": return <p>Done. Your mint is confirmed.</p>;23    case "error":   return <p role="alert">{state.message}</p>;24    default:        return null;25  }26}
Tip

Persist the pending hash to localStorage. If the user closes the tab and comes back, you can pick the transaction back up with waitForTransactionReceipt and restore the correct state instead of showing “idle”.

Write the copy for someone who does not think in nonces

The transaction and approval text is UX, and it is usually written by whoever wired up the contract call. It should not be.

DefaultRewritten
Approve USDCAllow this app to spend up to 250 USDC. You can revoke this anytime.
Transaction failed: execution revertedThis didn’t go through — the sale may have ended. Nothing was charged.
Insufficient funds for gasNot enough ETH to cover the network fee (~$3.20). Nothing was sent.

Do not make the UI wait on the chain

Reading state directly from the chain on every render is slow and gets rate-limited. Put an indexer between the chain and the UI — a subgraph, a hosted indexer, or your own event-log processor — so the interface reads from a fast database and only hits an RPC for the write path and for confirmations.

If a normal person cannot read your transaction modal and understand what they are agreeing to, the interface has failed — no matter how correct the contract call is.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.