← back/blog/wallet-stack

The EVM wallet stack, explained for builders

A field guide from raw window.ethereum calls to production wagmi v2.

20 min read·shreyaspadmakiran.com
EVM wallet stack — hex node graph of EIPs across the four wallet layers (provider, signing, accounts, RPC)

I spent a lot of time being confused about wallets. Not the user-facing stuff, but the actual plumbing underneath. Why does window.ethereum break when you have two extensions installed? Why does the same signing code behave differently on mobile? What is the actual difference between personal_sign and eth_signTypedData_v4, and when does it matter?

I kept hitting walls because I was treating wallets as a black box. Once I sat down and read through the EIPs, things clicked into place pretty fast. Turns out the wallet layer is not that complicated once you see it as four separate concerns stacked on top of each other.

This post is what I wish someone had handed me before I started building. We'll go from raw browser APIs up to production wagmi v2 patterns, and I'll call out the specific things that tripped me up along the way.


The four layers

Everything in the EVM wallet space lives in one of four layers. Keep this in your head as you read the rest of the post because it makes the whole thing a lot less overwhelming.

┌────────────────────────────────────────────────┐
│ Layer 4: App-to-Wallet RPC                     │
│ EIP-5792 (wallet_sendCalls, capabilities)      │
│ EIP-3085 / EIP-3326 (addChain / switchChain)   │
├────────────────────────────────────────────────┤
│ Layer 3: Account Model                         │
│ EIP-55 (checksum) · ERC-4337 (smart accounts)  │
│ EIP-7702 (EOA-with-code) · ERC-7579 (modular)  │
├────────────────────────────────────────────────┤
│ Layer 2: Signing                               │
│ EIP-191 (personal_sign) · EIP-712 (typed data) │
│ EIP-1271 (contract sigs) · EIP-2612 (permit)   │
├────────────────────────────────────────────────┤
│ Layer 1: Provider / Transport / Discovery      │
│ EIP-1193 (the API contract) · EIP-6963 (discov)│
│ WalletConnect v2 · Embedded wallets            │
└────────────────────────────────────────────────┘

Pretty much any issue you run into when integrating wallets traces back to one of these four layers.


Layer 1: providers, transports, and discovery

EIP-1193: the shared contract

EIP-1193 is the bedrock. It says every wallet must expose a request method and a handful of events. That's it. MetaMask, Trust Wallet, Coinbase Wallet, Rabby, Rainbow all implement this interface. When you call window.ethereum.request({ method: 'eth_requestAccounts' }) you are using EIP-1193.

The full interface is tiny:

interface EIP1193Provider {
  request(args: { method: string; params?: unknown[] }): Promise<unknown>
  on(event: 'connect' | 'disconnect' | 'chainChanged' | 'accountsChanged' | 'message', listener: Function): void
  removeListener(event: string, listener: Function): void
}

Everything you build on top of this is just composing RPC calls through that request method. The error codes are also standardized and you will see these constantly:

CodeWhat it means
4001User rejected the request
4100Wallet is locked or not authorized
4900Provider disconnected
4901Not connected to the requested chain

Here is a raw connect with no library, just EIP-1193:

const connect = async () => {
  if (!window.ethereum) throw new Error('No wallet installed')

  const accounts = await window.ethereum.request({
    method: 'eth_requestAccounts'
  })

  const chainId = await window.ethereum.request({
    method: 'eth_chainId'
  })

  window.ethereum.on('accountsChanged', (accounts: string[]) => {
    console.log('Accounts changed:', accounts)
  })

  window.ethereum.on('chainChanged', (_chainId: string) => {
    // reload on chain change, stale chain state causes subtle bugs
    window.location.reload()
  })

  return { accounts, chainId }
}

The window.ethereum race condition and how EIP-6963 fixed it

Here is something that frustrated me for way too long. When you have multiple wallet extensions installed, they all try to write to window.ethereum. The last one to load wins, and whatever wallet lost the race is completely invisible to your dApp. Install MetaMask and Rabby at the same time and one of them simply does not exist from the dApp's perspective.

EIP-6963 solved this by moving away from a shared global and into an event-based discovery system. Each wallet announces itself by firing an event with a {info, provider} pair. Your dApp listens for these announcements, collects all of them, then shows the user a list to pick from.

type EIP6963ProviderInfo = {
  uuid: string  // stable identifier per extension install
  name: string  // "MetaMask", "Trust Wallet", "Rabby", etc.
  icon: string  // data URI for the wallet icon
  rdns: string  // reverse DNS: "io.metamask", "com.trustwallet.app"
}

type EIP6963ProviderDetail = {
  info: EIP6963ProviderInfo
  provider: any  // standard EIP-1193 provider
}

const providers: EIP6963ProviderDetail[] = []

window.addEventListener('eip6963:announceProvider', (e: any) => {
  providers.push(e.detail)
})

// fire this to trigger all installed wallets to announce themselves
window.dispatchEvent(new Event('eip6963:requestProvider'))

// rdns values you will encounter:
// io.metamask         -> MetaMask
// com.trustwallet.app -> Trust Wallet
// com.coinbase.wallet -> Coinbase Wallet
// io.rabby            -> Rabby
// xyz.rainbow         -> Rainbow

All major wallets support EIP-6963 now. In wagmi v2, the injected() connector handles all of this automatically so you get multi-wallet discovery without any extra work on your end.

WalletConnect v2: the mobile and QR transport

When users are on mobile or don't have an extension installed, you need WalletConnect. The way it works is your dApp and the wallet both connect to a relay server over WebSocket and exchange messages through it. The connection is initiated via a QR code or a deep link.

Version 2 introduced a concept called CAIP-25 namespaces, which is where a lot of confusion comes from. You now have to declare which chains you need upfront when the session starts.

import { EthereumProvider } from '@walletconnect/ethereum-provider'

const provider = await EthereumProvider.init({
  projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!, // mandatory in v2
  chains: [1],                       // chains you absolutely need
  optionalChains: [137, 42161, 8453], // chains you'd like but don't require
  showQrModal: true,
})

provider.on('display_uri', (uri: string) => {
  // this is the WC pairing URI, show it as a QR code
  // or open it as a deep link to launch the wallet app
})

await provider.connect()

The gotcha here: if a user tries to switch to a chain that was not in chains or optionalChains when the session was created, it will fail silently in a confusing way. Most libraries expose a flag like isNewChainsStale: false to work around this. Make sure you set it when configuring WalletConnect through wagmi.

Embedded wallets

The fourth connection type is wallets that live entirely inside your app. Privy, Dynamic, Magic, Web3Auth, Turnkey all fall into this category. They create a key pair on behalf of the user, backed by email or Google or a passkey, with key material stored in TEEs or split via Shamir's scheme across multiple custodians.

From a code standpoint they give you an EIP-1193 provider or a viem wallet client, so they slot into wagmi like any other connector. The UX is completely different though, since users never install anything or leave your app. For consumer products targeting non-crypto users this changes the onboarding experience significantly.


Layer 2: signing

EIP-191: personal_sign

The simplest signing method. It prepends a standard prefix (\x19Ethereum Signed Message:\n followed by the message length) to your message before hashing it. This prefix is what prevents a signed message from being interpreted as a valid transaction by the EVM.

const sig = await window.ethereum.request({
  method: 'personal_sign',
  params: [
    `0x${Buffer.from('Sign in to MyApp - nonce: abc123').toString('hex')}`,
    accounts[0]
  ]
})

Every wallet supports this. Use it for SIWE flows and simple authentication.

EIP-712: typed structured data

The standard that makes signing human-readable. Instead of asking a user to sign a blob of hex they can't parse, EIP-712 lets wallets display named fields: "You are approving 100 USDC to 0xRouter at deadline 1234567890." This is the thing that actually prevents phishing via signed hashes, because users can read what they're signing.

const typedData = {
  types: {
    EIP712Domain: [
      { name: 'name',    type: 'string'  },
      { name: 'version', type: 'string'  },
      { name: 'chainId', type: 'uint256' },
    ],
    Permit: [
      { name: 'owner',    type: 'address' },
      { name: 'spender',  type: 'address' },
      { name: 'value',    type: 'uint256' },
      { name: 'nonce',    type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
    ],
  },
  primaryType: 'Permit',
  domain: { name: 'USD Coin', version: '2', chainId: 1 },
  message: {
    owner:    userAddress,
    spender:  routerAddress,
    value:    parseUnits('100', 6),
    nonce:    0n,
    deadline: BigInt(Math.floor(Date.now() / 1000) + 3600),
  },
}

const sig = await window.ethereum.request({
  method: 'eth_signTypedData_v4',
  params: [userAddress, JSON.stringify(typedData)]
})

Always use v4 (eth_signTypedData_v4). v1 and v3 have known issues. If you have hardware wallet users, note that some older Ledger firmware does not support v4, so you may need a fallback to personal_sign in those cases.

EIP-1271: contract signature validation

This is the one that bites you when you first start supporting smart account wallets. EOAs sign with private keys so you can verify them with ecrecover. Smart contract wallets like Safe, Coinbase Smart Wallet, or Trust Wallet SWIFT do not have private keys in the traditional sense. They validate signatures on-chain by implementing an isValidSignature function on their contract:

function isValidSignature(bytes32 hash, bytes signature)
  external view returns (bytes4) {
  // returns 0x1626ba7e on success, anything else means invalid
}

If your backend verifies signatures and you only use ecrecover, signatures from smart account users will silently fail. You need to check EIP-1271 as well. viem does this automatically:

import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'

const client = createPublicClient({ chain: mainnet, transport: http() })

const isValid = await client.verifyMessage({
  address: signer,
  message: 'Sign in to MyApp',
  signature: sig,
})
// handles plain EOAs via ecrecover
// and smart accounts via EIP-1271 on-chain call

EIP-6492 extends this further for counterfactual smart accounts, which are wallets that have been assigned an address but have not been deployed on-chain yet. It wraps the signature with a deployment sentinel so you can verify signatures from accounts that do not exist on-chain yet.

EIP-2612: permit

Normally approving an ERC-20 token costs a transaction and therefore gas. EIP-2612 lets a user sign an off-chain approval message instead, and then anyone (your dApp, a relayer, a Paymaster) can submit the actual permit() call on-chain. USDC, USDS, and most modern tokens ship with this.

const sig = await signTypedDataAsync({
  domain: {
    name: 'USD Coin',
    version: '2',
    chainId: 1,
    verifyingContract: USDC
  },
  types: {
    Permit: [
      { name: 'owner',    type: 'address' },
      { name: 'spender',  type: 'address' },
      { name: 'value',    type: 'uint256' },
      { name: 'nonce',    type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
    ]
  },
  primaryType: 'Permit',
  message: {
    owner:    address,
    spender:  VAULT,
    value:    amount,
    nonce,
    deadline: BigInt(deadline)
  },
})

// now pass the sig (v, r, s) into your contract's depositWithPermit()

Combine this with a Paymaster and you can build flows where users never need to hold ETH at all.


Layer 3: the account model

Three account types you need to understand

EOAs are traditional wallets. One private key, one address. MetaMask, Rabby, Rainbow, Trust Wallet in classic mode are all EOAs. Simple and battle-tested, but limited: one signer, no batching, no gas sponsorship.

ERC-4337 smart contract accounts are wallet contracts. Instead of regular transactions, they submit UserOperation objects to a global EntryPoint contract via a Bundler. The account runs custom signature logic via validateUserOp, which is how you get passkeys, multisig, and arbitrary signer schemes. A Paymaster can cover the gas. Safe, Coinbase Smart Wallet, and Trust Wallet SWIFT all run on ERC-4337.

Your dApp
   |
   | submits UserOperation (not a regular tx)
   v
[Bundler] (Pimlico, Alchemy, etc.)
   | calls EntryPoint.handleOps()
   v
[EntryPoint singleton contract]
   | calls validateUserOp() on the account
   | if valid, calls execute() on the account
   v
[Your Smart Contract Account]
   | optional: Paymaster covers gas costs

EIP-7702 is the newest and it went live on Ethereum mainnet on May 7, 2025 as part of the Pectra upgrade. It is a middle path between EOAs and full smart accounts. A new transaction type lets an EOA delegate to a smart contract by writing its address into the EOA's code slot. Your existing address gets smart account behavior (batching, passkeys, sponsored gas) without migrating to a new address. The delegation can also be removed. This is what "EOA upgrades" means in practice post-Pectra.

ERC-7579: modular smart accounts

As smart accounts proliferated, every team built their own extension system. ERC-7579 standardized it. Modules come in four types. Validators (type 1) run custom signature logic, things like passkeys, WebAuthn, and multisig. Executors (type 2) handle custom execution strategies like DCA or auto-compound. Fallback handlers (type 3) deal with callbacks like ERC-1271 and ERC-721 receiver. Hooks (type 4) add pre/post-call logic for things like spending limits and allowlists.

ZeroDev Kernel v3, Biconomy Nexus, Safe (via a Rhinestone adapter), and others all implement ERC-7579. The idea is that a module built for one account should work across all of them.


Layer 4: app-to-wallet RPC

Chain switching: actual behavior vs. the spec

Two methods cover this: wallet_switchEthereumChain to switch to a chain the wallet knows about, and wallet_addEthereumChain to add a new one first. The spec says the switch method should return error code 4902 if the chain is not found. In production that is not always what happens.

MetaMask Mobile has shipped versions returning -32601 ("method not found"). Coinbase Wallet SDK has returned -32603 for unknown chains. Some wallets throw completely unstructured error messages with no code at all. Trust Wallet's mobile dApp browser can throw errors that don't match any of these.

The robust pattern is to catch all of them:

async function switchOrAdd(chainId: number, chainParams: AddEthereumChainParameter) {
  const hexChainId = `0x${chainId.toString(16)}`

  try {
    await provider.request({
      method: 'wallet_switchEthereumChain',
      params: [{ chainId: hexChainId }],
    })
  } catch (e: any) {
    const chainNotFound =
      e.code === 4902 ||
      e.code === -32603 ||
      e.code === -32601 ||
      /unrecognized chain/i.test(e.message) ||
      /chain.*not.*added/i.test(e.message)

    if (chainNotFound) {
      await provider.request({
        method: 'wallet_addEthereumChain',
        params: [{
          chainId: hexChainId,
          chainName: chainParams.chainName,
          nativeCurrency: chainParams.nativeCurrency,
          rpcUrls: chainParams.rpcUrls,
          blockExplorerUrls: chainParams.blockExplorerUrls,
        }],
      })
    } else {
      throw e
    }
  }
}

EIP-5792: the batched call API

This is the one worth paying attention to going forward. EIP-5792 adds wallet_getCapabilities and wallet_sendCalls to the standard provider API. Instead of sending one transaction at a time, you can send a batch and ask the wallet whether it can execute them atomically and whether it supports gas sponsorship.

// check what this wallet can actually do
const capabilities = await provider.request({
  method: 'wallet_getCapabilities'
})

// response looks something like:
// {
//   "0x1": {
//     "atomic": { "status": "supported" },
//     "paymasterService": { "supported": true }
//   }
// }

const chainCaps = capabilities[`0x${chainId.toString(16)}`]

if (chainCaps?.atomic?.status === 'supported') {
  // send a batch: approve + swap in one shot, atomically
  const { id } = await provider.request({
    method: 'wallet_sendCalls',
    params: [{
      version: '1.0',
      chainId: `0x${chainId.toString(16)}`,
      from: address,
      atomicRequired: true,
      calls: [
        { to: TOKEN, data: approveCalldata },
        { to: ROUTER, data: swapCalldata },
      ],
      capabilities: chainCaps.paymasterService
        ? { paymasterService: { url: 'https://your-paymaster.example.com' } }
        : undefined,
    }]
  })

  // poll for status
  let result
  while (!result?.receipts) {
    await new Promise(r => setTimeout(r, 1000))
    result = await provider.request({
      method: 'wallet_getCallsStatus',
      params: [id],
    })
  }
}

Coinbase Smart Wallet supports this today. MetaMask and Trust Wallet are adding support as they roll out EIP-7702. If you write against wallet_sendCalls once and fall back to eth_sendTransaction for wallets that don't support it yet, your code stays the same as the ecosystem catches up.


Connecting wallets to your frontend

The mental model is simple. Every connection technique is just a way to get hold of an EIP-1193 provider. The source changes but the interface is always the same.

Browser extension     ->  window.ethereum (EIP-1193 provider)
EIP-6963 discovery    ->  event.detail.provider (EIP-1193 provider)
WalletConnect v2      ->  EthereumProvider instance (EIP-1193 provider)
Coinbase Smart Wallet ->  CoinbaseWalletProvider (EIP-1193 provider)
Privy / Dynamic       ->  embedded EIP-1193 or viem wallet client

Once you have the provider, all the RPC calls are identical across every wallet. That's the whole point of EIP-1193.

Detecting specific wallets

Different wallets inject different markers onto the provider. Here is what to check:

const isMetaMask = window.ethereum?.isMetaMask === true
const isTrust    = window.ethereum?.isTrust === true
              || window.trustwallet?.ethereum?.isTrust === true
const isCoinbase = window.ethereum?.isCoinbaseWallet === true
const isRabby    = window.ethereum?.isRabby === true

Trust Wallet is notable here because it also injects window.trustwallet.solana, window.trustwallet.cosmos, and window.trustwallet.aptos for non-EVM chains. It's the only major wallet that does multi-VM injection from a single namespace in the browser.

That said, the better approach is to rely on EIP-6963 rdns values rather than window globals that can get clobbered. The stable identifiers are:

Mobile wallet behavior

Mobile is where wallets diverge the most. A few things worth knowing:

MetaMask Mobile ships with an in-app dApp browser and also supports WalletConnect v2 for connecting to desktop dApps from the mobile wallet.

Trust Wallet removed its in-app dApp browser on iOS back in 2021 to comply with App Store policies. On Android the in-app browser still works. But if you're building for iOS users connecting to Trust Wallet from an external browser, WalletConnect v2 is the only path that works. Trust's trust:// deep links are for things like sending and swapping, not for dApp session establishment. Routing users through those for wallet connection will fail on iOS.

Coinbase Wallet connects via its SDK with QR or a deep link and decides at connection time whether to use the mobile app or the Smart Wallet popup based on what the user has set up.

Rainbow and Rabby both use WalletConnect v2 for mobile in all cases.


Building with wagmi v2

wagmi v2 is the standard way to handle wallet interactions in a React app. It sits on top of viem (which handles types and low-level RPC) and TanStack Query (which handles caching, retries, and state). viem gives you the primitives, TanStack Query gives you async state management, and wagmi hooks tie them together.

Setup

// config.ts
import { http, createConfig, cookieStorage, createStorage } from 'wagmi'
import { mainnet, base, arbitrum } from 'wagmi/chains'
import { injected, walletConnect, coinbaseWallet, safe } from 'wagmi/connectors'

export const config = createConfig({
  chains: [mainnet, base, arbitrum],
  connectors: [
    injected({ shimDisconnect: true }),  // EIP-6963 multi-wallet discovery
    walletConnect({
      projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
      metadata: {
        name: 'MyApp',
        description: 'My dApp',
        url: 'https://myapp.xyz',
        icons: ['https://myapp.xyz/icon.png'],
      },
    }),
    coinbaseWallet({
      appName: 'MyApp',
      preference: 'all', // use 'smartWalletOnly' to force the Coinbase Smart Wallet popup
    }),
    safe(),
  ],
  transports: {
    [mainnet.id]:  http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
    [base.id]:     http('https://base-mainnet.g.alchemy.com/v2/YOUR_KEY'),
    [arbitrum.id]: http(),
  },
  ssr: true,
  storage: createStorage({ storage: cookieStorage }),
})
// providers.tsx for Next.js App Router
'use client'
import { WagmiProvider, cookieToInitialState } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { config } from './config'

const queryClient = new QueryClient()

export function Providers({
  children,
  cookie,
}: {
  children: React.ReactNode
  cookie: string | null
}) {
  const initialState = cookieToInitialState(config, cookie)
  return (
    <WagmiProvider config={config} initialState={initialState}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  )
}
// app/layout.tsx — pass the cookie from the server into the provider
import { headers } from 'next/headers'
import { Providers } from './providers'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const cookie = headers().get('cookie')
  return (
    <html>
      <body>
        <Providers cookie={cookie}>{children}</Providers>
      </body>
    </html>
  )
}

The SSR setup is one of the most commonly skipped things when people set up wagmi for the first time. Skip it and you get hydration errors because the server renders the disconnected state and the client renders the connected state and React throws a fit.

Connecting and account state

import { useAccount, useConnect, useDisconnect, useConnectors, useSwitchChain } from 'wagmi'

export function WalletSection() {
  const { address, isConnected, chain } = useAccount()
  const { connect, isPending, error } = useConnect()
  const { disconnect } = useDisconnect()
  const { switchChain } = useSwitchChain()
  const connectors = useConnectors()

  if (isConnected) {
    return (
      <div>
        <p>{address}</p>
        <p>Chain: {chain?.name}</p>
        <button onClick={() => switchChain({ chainId: 8453 })}>
          Switch to Base
        </button>
        <button onClick={() => disconnect()}>Disconnect</button>
      </div>
    )
  }

  return (
    <div>
      {connectors.map(connector => (
        <button
          key={connector.uid}
          onClick={() => connect({ connector })}
          disabled={isPending}
        >
          {connector.name}
        </button>
      ))}
      {error && <p>Error: {error.message}</p>}
    </div>
  )
}

Type-safe ABIs

Always write ABIs with as const. Without it, wagmi and viem cannot infer the types of your function parameters and return values, which removes a lot of the value of using TypeScript here.

// no type inference on args or return values without as const
const abi = [{ type: 'function', name: 'balanceOf', /* ... */ }]

// full inference with it
export const erc20Abi = [
  {
    type: 'function',
    name: 'balanceOf',
    stateMutability: 'view',
    inputs: [{ name: 'owner', type: 'address' }],
    outputs: [{ type: 'uint256' }],
  },
  {
    type: 'function',
    name: 'transfer',
    stateMutability: 'nonpayable',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' },
    ],
    outputs: [{ type: 'bool' }],
  },
  {
    type: 'function',
    name: 'approve',
    stateMutability: 'nonpayable',
    inputs: [
      { name: 'spender', type: 'address' },
      { name: 'amount', type: 'uint256' },
    ],
    outputs: [{ type: 'bool' }],
  },
] as const

If you have deployment artifacts, wagmi-cli can generate fully typed hooks from them directly.

Reading on-chain data

import { useReadContract, useReadContracts, useBalance } from 'wagmi'
import { erc20Abi } from './abi'

function Portfolio({ address }: { address: `0x${string}` }) {
  // native ETH balance
  const { data: ethBalance } = useBalance({ address })

  // single contract read
  const { data: usdcBalance } = useReadContract({
    address: '0xA0b86991c6218b36c1d19D4a2e9EbOcE3606eB48',
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: [address],
    query: {
      enabled: !!address,
      staleTime: 30_000,       // skip refetch if data is under 30s old
      refetchInterval: 60_000, // poll every 60s in the background
    },
  })

  // multicall batches multiple reads into one RPC request
  const { data: tokenData } = useReadContracts({
    contracts: [
      { address: USDC, abi: erc20Abi, functionName: 'symbol' },
      { address: USDC, abi: erc20Abi, functionName: 'decimals' },
      { address: USDC, abi: erc20Abi, functionName: 'totalSupply' },
    ],
    allowFailure: true, // one failing read won't kill the entire batch
  })

  // pin a read to a specific chain regardless of what the user is connected to
  const { data: baseBalance } = useReadContract({
    chainId: 8453,
    address: BASE_TOKEN,
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: [address],
  })
}

Writing transactions

The correct pattern is simulate first, then write, then wait. Simulating first tells you whether the transaction will revert before the wallet popup even opens. Users don't have to confirm something that will fail.

import {
  useSimulateContract,
  useWriteContract,
  useWaitForTransactionReceipt,
} from 'wagmi'
import { parseUnits } from 'viem'
import { erc20Abi } from './abi'

function TransferButton({
  token,
  to,
  amount,
}: {
  token: `0x${string}`
  to: `0x${string}`
  amount: string
}) {
  const { address } = useAccount()

  // step 1: simulate to check for reverts before touching the wallet
  const {
    data: sim,
    error: simError,
    isPending: simLoading,
  } = useSimulateContract({
    address: token,
    abi: erc20Abi,
    functionName: 'transfer',
    args: [to, parseUnits(amount, 6)],
    query: { enabled: !!address && !!amount && !!to },
  })

  // step 2: write, only triggered when the user clicks
  const {
    writeContract,
    data: txHash,
    isPending: waitingForWallet,
    error: writeError,
  } = useWriteContract()

  // step 3: wait for mining
  const { isLoading: isMining, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
    query: { enabled: !!txHash },
  })

  const isDisabled = !sim || waitingForWallet || isMining || simLoading

  return (
    <div>
      <button
        disabled={isDisabled}
        onClick={() => writeContract(sim!.request)}
      >
        {waitingForWallet ? 'Confirm in wallet...'
         : isMining ? 'Mining...'
         : isSuccess ? 'Done'
         : 'Send'}
      </button>

      {simError && (
        <p style={{ color: 'red' }}>
          Transaction will revert: {simError.shortMessage}
        </p>
      )}
    </div>
  )
}

Signing

import { useSignMessage, useSignTypedData } from 'wagmi'

function SignSection() {
  const { signMessageAsync } = useSignMessage()
  const { signTypedDataAsync } = useSignTypedData()

  const handleSiwe = async () => {
    const sig = await signMessageAsync({
      message: `Sign in to MyApp\nNonce: ${nonce}\nIssued: ${new Date().toISOString()}`
    })
    // send sig + address to your backend for verification
  }

  const handlePermit = async () => {
    const sig = await signTypedDataAsync({
      domain: {
        name: 'USD Coin',
        version: '2',
        chainId: 1,
        verifyingContract: USDC
      },
      types: {
        Permit: [
          { name: 'owner',    type: 'address' },
          { name: 'spender',  type: 'address' },
          { name: 'value',    type: 'uint256' },
          { name: 'nonce',    type: 'uint256' },
          { name: 'deadline', type: 'uint256' },
        ]
      },
      primaryType: 'Permit',
      message: {
        owner:    address,
        spender:  VAULT,
        value:    100_000000n,
        nonce:    0n,
        deadline: BigInt(deadline)
      },
    })
  }
}

Watch events instead of polling

import { useWatchContractEvent } from 'wagmi'

useWatchContractEvent({
  address: VAULT,
  abi: vaultAbi,
  eventName: 'Deposit',
  onLogs: (logs) => {
    logs.forEach(log => {
      console.log('Deposit:', log.args)
      queryClient.invalidateQueries({ queryKey: ['balance', address] })
    })
  },
})

Invalidating reads after writes

Every wagmi query hook exposes a queryKey. After a write completes, invalidate the relevant reads to refresh the UI immediately instead of waiting for the next poll:

import { useQueryClient } from '@tanstack/react-query'
import { useReadContract, useWriteContract } from 'wagmi'

const queryClient = useQueryClient()

const balanceQuery = useReadContract({
  address: TOKEN,
  abi: erc20Abi,
  functionName: 'balanceOf',
  args: [address]
})

const { writeContract } = useWriteContract({
  mutation: {
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: balanceQuery.queryKey })
    }
  }
})

Things that will catch you off guard

These are the issues that don't show up in tutorials but that you will hit in production.

eth_sign is dangerous and you should not use it. It signs a raw 32-byte hash, which means a signed message could technically be a valid transaction. MetaMask hides it by default now. Use personal_sign or eth_signTypedData_v4.

BigInt literals are required. args: [100] causes a runtime error because viem expects bigint, not number. Use 100n, parseUnits('100', 6), or BigInt(100).

SSR hydration mismatches in Next.js. wagmi reads connection state from storage on the client. The server renders disconnected, the client renders connected, React throws a hydration error. Fix it by setting ssr: true and cookieStorage in your config and passing cookieToInitialState from your layout.

Smart account signature verification on your backend. If you do SIWE or any server-side signature verification and you use plain ecrecover, it will fail for users on Safe, Coinbase Smart Wallet, Trust Wallet SWIFT, or any ERC-4337 account. Use viem's verifyMessage which handles both EOAs via ecrecover and EIP-1271 contract verification.

Stale WalletConnect sessions. WC v2 persists sessions in localStorage under wc@2:* keys. A leftover session from a previous visit can silently reconnect to a disconnected wallet when a new user loads the page. Call wagmi's disconnect() on logout to clear it. If you're debugging weird reconnection behavior, clearing all wc@2:* keys in localStorage is the first thing to try.

Calling writeContract without a simulation result. If sim is undefined because the simulation has not resolved yet, passing it to writeContract blows up. Always gate your write button on !!sim.

Chain add vs chain switch behavior. After wallet_addEthereumChain succeeds, some wallets automatically switch to the new chain. Others don't. Always follow up with wallet_switchEthereumChain after adding.

Trust Wallet on iOS has no in-app dApp browser. It was removed to comply with App Store policies. If a user is on iOS and you route them through a Trust Wallet deep link to open a dApp, it will fail. WalletConnect v2 is how Trust Wallet connects on iOS and you should use that as your default mobile path.


Adding a UI kit

RainbowKit is the fastest way to get a production-ready wallet connection modal without building one yourself.

// config.ts
import { getDefaultConfig } from '@rainbow-me/rainbowkit'
import { mainnet, base } from 'wagmi/chains'

export const config = getDefaultConfig({
  appName: 'MyApp',
  projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
  chains: [mainnet, base],
  ssr: true,
})
// providers.tsx
import { RainbowKitProvider } from '@rainbow-me/rainbowkit'
import '@rainbow-me/rainbowkit/styles.css'
// wrap: WagmiProvider -> QueryClientProvider -> RainbowKitProvider
import { ConnectButton } from '@rainbow-me/rainbowkit'

<ConnectButton />

RainbowKit uses EIP-6963 internally, so MetaMask, Trust Wallet, Rabby, Coinbase Wallet, Rainbow, and any other EIP-6963-compliant wallet show up in the list automatically.


Where things are heading

EIP-7702 went live on Ethereum mainnet on May 7, 2025. The practical effect is that existing EOA users can get smart account features without moving to a new address. Wallets are shipping support at different speeds. MetaMask has the Delegation Toolkit. Trust Wallet's smart contract team co-authored ERC-7779, which standardizes how delegations can move between wallet vendors so users aren't locked into one wallet after delegating.

EIP-5792 is the write path to build toward. Write against wallet_sendCalls and check capabilities first. Coinbase Smart Wallet supports it today. Other wallets are adding it as they ship EIP-7702 and smart account support. Your dApp code stays the same as the ecosystem catches up.

The signing layer is moving toward passkeys. WebAuthn with secp256r1 is the signer scheme behind Coinbase Smart Wallet, Trust Wallet SWIFT, and most new embedded wallets. EIP-7212 adds a native precompile for cheap P-256 verification on L2s. If you're building a new wallet experience, passkeys are quickly becoming the baseline expectation for onboarding UX.


Quick reference

What I want to doWhat to use
Support all installed browser walletsinjected() connector via EIP-6963
Support mobile users on iOSWalletConnect v2
Basic message signing for SIWEpersonal_sign via EIP-191
Human-readable structured signingeth_signTypedData_v4 via EIP-712
Verify signatures from smart accountsEIP-1271 via viem.verifyMessage
Gasless token approvalsEIP-2612 permit
Batch transactionsEIP-5792 wallet_sendCalls with eth_sendTransaction fallback
React wallet integrationwagmi v2 + viem + TanStack Query
Check a tx won't revert before popupuseSimulateContract before useWriteContract
React to on-chain eventsuseWatchContractEvent

Further reading

— shreyas