Wallet Integration skill

Wallet Integration is an agent skill for AI coding assistants (Claude Code, OpenClaw, Cursor, Codex). Web3 wallet integration for React/Next.js dApps — RainbowKit, ConnectKit, WalletConnect, wagmi v2/viem, contract reads/writes, EIP-712 signing, chain switching, and SSR-safe hydration. Use when connecting wallets, sending transactions, signing messages, or fixing wallet hydration/precision/security issues. Install with: npx skills-ws install wallet-integration.

web3v1.0.0Updated
copied ✓
openclawclaude-codecursorcodex
0 installsSecurity scan: cleanSource code

Web3 Wallet Integration

Stack: wagmi v2 + viem v2 + @tanstack/react-query v5. ethers-era patterns are out. RainbowKit and ConnectKit are wallet-UI layers on top of wagmi. Note: wagmi v3 is the latest major (it renames useAccount to useConnection and hook action functions to mutate/mutateAsync, and makes connector SDKs optional peer deps), but RainbowKit and ConnectKit still peer-require wagmi 2.x, so this skill targets wagmi v2: install wagmi@2, not latest. For kit-free builds you can adopt wagmi v3 via https://wagmi.sh/react/guides/migrate-from-v2-to-v3 (the hooks below need the v3 renames applied).

Address typing rule (read first). wagmi/viem use the template-literal type `0x${string}` for every address. A placeholder like '0xRecipient...' (with a literal ...) is not assignable to that type — TypeScript will reject it and the example won't compile. Every address in this skill is a full 40-hex-char value. None of these are real or safe to use on mainnet — replace them with addresses you have verified for the correct chain. Centralize them so they're easy to swap:

// addresses.ts — verified per chain; replace before mainnet use.
import type { Address } from 'viem';

// USDC on Ethereum mainnet (chainId 1). USDC has a DIFFERENT address on every chain —
// never reuse an address across chains. Look up the canonical address per chain.
export const USDC_MAINNET: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';

// Obvious dummy recipients/contracts for examples. Do NOT send funds here.
export const RECIPIENT: Address      = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; // Anvil acct #1
export const EXAMPLE_CONTRACT: Address = '0x5FbDB2315678afecb367f032d93F642f64180aa3'; // Anvil deploy #0
export const ZERO_ADDRESS: Address   = '0x0000000000000000000000000000000000000000';

1. wagmi v2 + viem Setup

Install Dependencies

npm install wagmi@2 viem @tanstack/react-query
# For wallet UI kit (pick one):
npm install @rainbow-me/rainbowkit    # RainbowKit
# OR
npm install connectkit                  # ConnectKit

Configuration (wagmi.ts)

import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base, celo, sepolia } from 'wagmi/chains';
import { injected, walletConnect, coinbaseWallet } from 'wagmi/connectors';

const projectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID!; // WalletConnect Cloud

export const config = createConfig({
  chains: [mainnet, polygon, arbitrum, base, celo, sepolia],
  connectors: [
    injected(),
    walletConnect({ projectId }),
    coinbaseWallet({ appName: 'My dApp' }),
  ],
  transports: {
    [mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
    [polygon.id]: http('https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY'),
    [arbitrum.id]: http('https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY'),
    [base.id]: http('https://base-mainnet.g.alchemy.com/v2/YOUR_KEY'),
    [celo.id]: http('https://forno.celo.org'),
    [sepolia.id]: http('https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY'),
  },
});

Provider Setup (App.tsx)

import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from './wagmi';

const queryClient = new QueryClient();

export default function App({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  );
}

2. RainbowKit Quick Start

Setup (client-only / SPA)

npm install @rainbow-me/rainbowkit wagmi@2 viem @tanstack/react-query
import '@rainbow-me/rainbowkit/styles.css';
import { getDefaultConfig, RainbowKitProvider, ConnectButton } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { mainnet, polygon, arbitrum, base, celo } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const config = getDefaultConfig({
  appName: 'My dApp',
  projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
  chains: [mainnet, polygon, arbitrum, base, celo],
});

const queryClient = new QueryClient();

export default function App({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          {children}
          <ConnectButton />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

Next.js App Router (SSR-safe — avoids hydration mismatch)

ssr: true alone is not enough. On the server wagmi has no localStorage, so the connected account renders as disconnected; the client then hydrates connected and React throws a hydration mismatch (and the wallet may appear to "flicker" disconnected on every reload). The fix is to persist connection state in a cookie, read it in the server layout, and pass it as initialState. This is the current wagmi + RainbowKit Next.js pattern (as of Jun 2026; verify at https://rainbowkit.com/docs/installation and https://wagmi.sh/react/guides/ssr).

// app/wagmi.ts  — config with ssr + cookieStorage
import { cookieStorage, createStorage } from 'wagmi';
import { getDefaultConfig } from '@rainbow-me/rainbowkit';
import { mainnet, polygon, arbitrum, base, celo } from 'wagmi/chains';

export function getConfig() {
  return getDefaultConfig({
    appName: 'My dApp',
    projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
    chains: [mainnet, polygon, arbitrum, base, celo],
    ssr: true,
    storage: createStorage({ storage: cookieStorage }), // persist to cookie, not localStorage
  });
}
// app/providers.tsx  — 'use client'; rebuild config once per mount, seed initialState
'use client';
import '@rainbow-me/rainbowkit/styles.css';
import { useState, type ReactNode } from 'react';
import { WagmiProvider, type State } from 'wagmi';
import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getConfig } from './wagmi';

export function Providers({ children, initialState }: { children: ReactNode; initialState?: State }) {
  const [config] = useState(() => getConfig());
  const [queryClient] = useState(() => new QueryClient());
  return (
    <WagmiProvider config={config} initialState={initialState}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>{children}</RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}
// app/layout.tsx  — server component reads the cookie and hydrates state
import { headers } from 'next/headers';
import { cookieToInitialState } from 'wagmi';
import { getConfig } from './wagmi';
import { Providers } from './providers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const initialState = cookieToInitialState(
    getConfig(),
    (await headers()).get('cookie'), // Next.js 15+: headers() is async
  );
  return (
    <html lang="en">
      <body>
        <Providers initialState={initialState}>{children}</Providers>
      </body>
    </html>
  );
}

Still seeing mismatches on a single <ConnectButton />? Gate any wallet-derived UI on a mounted flag (see the Custom Connect Button below — it already checks mounted) so the first client paint matches the server.

Custom Connect Button

import { ConnectButton } from '@rainbow-me/rainbowkit';

export function CustomConnect() {
  return (
    <ConnectButton.Custom>
      {({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted }) => {
        const connected = mounted && account && chain;
        return (
          <div>
            {!connected ? (
              <button onClick={openConnectModal}>Connect Wallet</button>
            ) : chain.unsupported ? (
              <button onClick={openChainModal}>Wrong Network</button>
            ) : (
              <div style={{ display: 'flex', gap: 8 }}>
                <button onClick={openChainModal}>{chain.name}</button>
                <button onClick={openAccountModal}>
                  {account.displayName}
                  {account.displayBalance ? ` (${account.displayBalance})` : ''}
                </button>
              </div>
            )}
          </div>
        );
      }}
    </ConnectButton.Custom>
  );
}

3. ConnectKit Alternative

npm install connectkit wagmi@2 viem @tanstack/react-query
import { ConnectKitProvider, ConnectKitButton, getDefaultConfig } from 'connectkit';
import { WagmiProvider, createConfig } from 'wagmi';
import { mainnet, polygon } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const config = createConfig(
  getDefaultConfig({
    chains: [mainnet, polygon],
    walletConnectProjectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
    appName: 'My dApp',
  })
);

const queryClient = new QueryClient();

export default function App({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <ConnectKitProvider>
          {children}
          <ConnectKitButton />
        </ConnectKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

For Next.js App Router, wrap ConnectKit with the same ssr: true + cookieStorage + initialState pattern shown above for RainbowKit — the hydration concern is identical.


4. Contract Read/Write Hooks

Read Contract Data

import { useReadContract } from 'wagmi';
import { erc20Abi, formatUnits, type Address } from 'viem';

function TokenBalance({ token, account }: { token: Address; account: Address }) {
  const { data: balance, isLoading, error } = useReadContract({
    address: token,
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: [account],
  });

  const { data: decimals } = useReadContract({
    address: token,
    abi: erc20Abi,
    functionName: 'decimals',
  });

  const { data: symbol } = useReadContract({
    address: token,
    abi: erc20Abi,
    functionName: 'symbol',
  });

  if (isLoading) return <span>Loading...</span>;
  if (error) return <span>Error: {error.message}</span>;

  // NEVER do `Number(balance) / 10 ** decimals`: ERC-20 balances are `bigint` (uint256).
  // Number maxes out at 2^53-1, so an 18-decimal token balance silently loses precision.
  // viem's formatUnits keeps full precision and returns a string.
  const formatted =
    balance !== undefined && decimals !== undefined
      ? Number(formatUnits(balance, decimals)).toLocaleString(undefined, { maximumFractionDigits: 4 })
      : '0';

  return <span>{formatted} {symbol}</span>;
}

formatUnits(value, decimals) (bigint → display string) and parseUnits('1.5', decimals) (display string → bigint) are the only correct way to convert token amounts. formatEther/parseEther are the 18-decimal shorthands. Do arithmetic on the raw bigint, format only at the display edge.

Write Contract (simulate → write → wait)

Production writes follow a strict order: verify the chain → simulate → write the simulated request → wait for receipt. useSimulateContract does an eth_call against the current state, so it surfaces reverts (insufficient balance, paused contract, bad args) before the wallet popup and gives writeContract a fully-typed, gas-estimated request. Sending a raw object instead skips that safety net.

import {
  useWriteContract,
  useWaitForTransactionReceipt,
  useSimulateContract,
  useChainId,
} from 'wagmi';
import { parseUnits } from 'viem';
import { mainnet } from 'wagmi/chains';
import { USDC_MAINNET, RECIPIENT } from './addresses';

const erc20Abi = [
  {
    name: 'transfer',
    type: 'function',
    stateMutability: 'nonpayable',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' },
    ],
    outputs: [{ type: 'bool' }],
  },
] as const;

function TransferToken() {
  const chainId = useChainId();
  const onMainnet = chainId === mainnet.id; // token address below is mainnet-only

  // 1. Simulate first. `simulateData?.request` is a typed, ready-to-send tx.
  const { data: simulateData, error: simError } = useSimulateContract({
    address: USDC_MAINNET,
    abi: erc20Abi,
    functionName: 'transfer',
    args: [RECIPIENT, parseUnits('100', 6)], // USDC has 6 decimals
    query: { enabled: onMainnet },           // don't simulate against the wrong chain
  });

  const { data: hash, writeContract, isPending, error } = useWriteContract();
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });

  if (!onMainnet) return <p>Switch to Ethereum mainnet to send USDC.</p>;
  if (simError)   return <p>Cannot send: {simError.message}</p>; // revert caught pre-popup

  return (
    <div>
      <button
        onClick={() => simulateData && writeContract(simulateData.request)}
        disabled={!simulateData || isPending}
      >
        {isPending ? 'Confirm in wallet...' : 'Send 100 USDC'}
      </button>
      {isConfirming && <p>Waiting for confirmation...</p>}
      {isSuccess && <p>Transfer confirmed! TX: {hash}</p>}
      {error && <p>Error: {error.message}</p>}
    </div>
  );
}

Token addresses are per-chain. USDC_MAINNET is only valid on chainId 1 — sending it while connected to Polygon either reverts or hits an unrelated contract. Always gate writes on chainId and resolve the token address from a per-chain map.

Send Native ETH

import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
import { RECIPIENT } from './addresses';

function SendEth() {
  const { data: hash, sendTransaction, isPending } = useSendTransaction();
  const { isSuccess } = useWaitForTransactionReceipt({ hash });

  return (
    <button onClick={() => sendTransaction({
      to: RECIPIENT,               // full 0x-address from your verified constants
      value: parseEther('0.1'),    // 0.1 ETH as bigint wei
    })}>
      {isPending ? 'Sending...' : 'Send 0.1 ETH'}
    </button>
  );
}

5. EIP-712 Typed Message Signing

import { useSignTypedData, useAccount, useChainId } from 'wagmi';
import { USDC_MAINNET, RECIPIENT } from './addresses';

function SignPermit() {
  const { address: owner } = useAccount();
  const chainId = useChainId();
  const { signTypedData, data: signature } = useSignTypedData();

  function handleSign() {
    if (!owner) return;
    signTypedData({
      domain: {
        name: 'USD Coin',
        version: '2',
        chainId,                       // bind the signature to the connected chain
        verifyingContract: USDC_MAINNET, // the token/contract you're permitting
      },
      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,                         // the connected account, not a hardcoded address
        spender: RECIPIENT,            // who you're granting an allowance to
        value: 1000000n,               // 1 USDC (6 decimals) — show this to the user verbatim
        nonce: 0n,                     // read from the token's nonces(owner) before signing
        deadline: BigInt(Math.floor(Date.now() / 1000) + 3600),
      },
    });
  }

  return (
    <div>
      <button onClick={handleSign}>Sign Permit</button>
      {signature && <p>Signature: {signature}</p>}
    </div>
  );
}

A Permit signature is a gasless approval — once signed, anyone can submit it to grant spender an allowance. Always render spender, value, the token name, and chain in your UI before calling signTypedData, fetch the live nonce from the contract, and prefer a finite value over type(uint256).max. See section 13 for the full signing-safety checklist.


6. Chain Switching

import { useSwitchChain, useChainId } from 'wagmi';
import { mainnet, polygon, arbitrum, base, celo } from 'wagmi/chains';

function ChainSwitcher() {
  const chainId = useChainId();
  const { switchChain, isPending } = useSwitchChain();
  const chains = [mainnet, polygon, arbitrum, base, celo];

  return (
    <div>
      {chains.map((chain) => (
        <button
          key={chain.id}
          onClick={() => switchChain({ chainId: chain.id })}
          disabled={chainId === chain.id || isPending}
        >
          {chain.name} {chainId === chain.id ? '✓' : ''}
        </button>
      ))}
    </div>
  );
}

7. ENS Resolution

import { useEnsName, useEnsAvatar, useEnsAddress } from 'wagmi';

function UserProfile({ address }: { address: `0x${string}` }) {
  const { data: ensName } = useEnsName({ address });
  const { data: ensAvatar } = useEnsAvatar({ name: ensName || undefined });

  return (
    <div>
      {ensAvatar && <img src={ensAvatar} alt="avatar" width={32} height={32} />}
      <span>{ensName || `${address.slice(0, 6)}...${address.slice(-4)}`}</span>
    </div>
  );
}

// Resolve ENS name to address
function ResolveENS({ name }: { name: string }) {
  const { data: address } = useEnsAddress({ name });
  return <span>{address || 'Not found'}</span>;
}

8. viem Client (Non-React)

import { createPublicClient, createWalletClient, http, custom, erc20Abi, type Hash } from 'viem';
import { mainnet } from 'viem/chains';
import { USDC_MAINNET, RECIPIENT } from './addresses';

// Read-only client
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
});

// Read data — addresses/hashes are typed `0x${string}`
const balance = await publicClient.getBalance({ address: RECIPIENT });
const blockNumber = await publicClient.getBlockNumber();
const someHash = '0x0000000000000000000000000000000000000000000000000000000000000000' as Hash;
const txReceipt = await publicClient.getTransactionReceipt({ hash: someHash });

// Read contract
const totalSupply = await publicClient.readContract({
  address: USDC_MAINNET,
  abi: erc20Abi,
  functionName: 'totalSupply',
});

// Wallet client (browser). `window.ethereum` is injected by the wallet (EIP-1193).
const walletClient = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum!),
});

const [account] = await walletClient.getAddresses();

// Simulate first, then send the validated request (same safety as useSimulateContract).
const { request } = await publicClient.simulateContract({
  address: USDC_MAINNET,
  abi: erc20Abi,
  functionName: 'transfer',
  args: [RECIPIENT, 1000000n], // 1 USDC (6 decimals)
  account,
});
const hash = await walletClient.writeContract(request);

9. TypeScript Contract Types

// Define contract ABI as const for full type inference
const vaultAbi = [
  {
    name: 'deposit',
    type: 'function',
    stateMutability: 'payable',
    inputs: [],
    outputs: [],
  },
  {
    name: 'withdraw',
    type: 'function',
    stateMutability: 'nonpayable',
    inputs: [{ name: 'amount', type: 'uint256' }],
    outputs: [],
  },
  {
    name: 'balanceOf',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: 'account', type: 'address' }],
    outputs: [{ type: 'uint256' }],
  },
  {
    name: 'Deposited',
    type: 'event',
    inputs: [
      { name: 'user', type: 'address', indexed: true },
      { name: 'amount', type: 'uint256', indexed: false },
    ],
  },
] as const;

// Full type safety in hooks (EXAMPLE_CONTRACT / RECIPIENT are typed `0x${string}`):
import { EXAMPLE_CONTRACT, RECIPIENT } from './addresses';

const { data } = useReadContract({
  address: EXAMPLE_CONTRACT,
  abi: vaultAbi,
  functionName: 'balanceOf', // autocomplete works
  args: [RECIPIENT],         // typed as [Address]
}); // data typed as bigint

10. Error Handling Patterns

import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { BaseError, ContractFunctionRevertedError, UserRejectedRequestError } from 'viem';

function MintNFT() {
  const { writeContract, error, data: hash, isPending } = useWriteContract();
  const { isLoading, isSuccess, error: receiptError } = useWaitForTransactionReceipt({ hash });

  function getErrorMessage(err: Error): string {
    if (err instanceof BaseError) {
      const revertError = err.walk((e) => e instanceof ContractFunctionRevertedError);
      if (revertError instanceof ContractFunctionRevertedError) {
        return revertError.data?.errorName || 'Contract reverted';
      }
      if (err.walk((e) => e instanceof UserRejectedRequestError)) {
        return 'Transaction rejected by user';
      }
    }
    return err.message;
  }

  return (
    <div>
      <button onClick={() => writeContract({ /* ... */ })} disabled={isPending || isLoading}>
        {isPending ? 'Confirm in wallet...' : isLoading ? 'Mining...' : 'Mint'}
      </button>
      {isSuccess && <p>✅ Minted! TX: {hash}</p>}
      {error && <p>❌ {getErrorMessage(error)}</p>}
    </div>
  );
}

11. Mobile Wallet Deep Links

Prefer WalletConnect for mobile. RainbowKit / ConnectKit already render a "Get the app" / QR flow over WalletConnect, which opens the wallet's in-app browser and survives across wallets — you rarely need to build deep links by hand. Reach for raw deep links only for a single, branded "Open in MetaMask" button.

// Deep-link builders. Host paths/params change — verify against each wallet's current docs
// (as of Jun 2026): MetaMask https://docs.metamask.io/wallet/how-to/use-mobile/ ,
// Coinbase Wallet https://docs.cdp.coinbase.com/ , Trust https://developer.trustwallet.com/ .
const deepLinks = {
  metamask: `https://metamask.app.link/dapp/${window.location.host}${window.location.pathname}`,
  trust: `https://link.trustwallet.com/open_url?url=${encodeURIComponent(window.location.href)}`,
  coinbase: `https://go.cb-w.com/dapp?cb_url=${encodeURIComponent(window.location.href)}`,
};

const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
// Render an EXPLICIT button. NEVER do `window.location.href = deepLink` automatically:
// auto-navigating on `!window.ethereum` hijacks the page, breaks the back button, and
// can be abused to bounce users to an attacker-controlled URL. Make wallet hand-off a click.
function OpenInWallet() {
  if (!isMobile || typeof window.ethereum !== 'undefined') return null; // wallet already present
  return (
    <a href={deepLinks.metamask} rel="noopener noreferrer">
      Open in MetaMask
    </a>
  );
}

12. WalletConnect / Reown Project ID

WalletConnect rebranded its developer platform to Reown; the dashboard now lives at https://dashboard.reown.com (the old cloud.walletconnect.com redirects there). Steps:

  1. Sign in at https://dashboard.reown.com and create a new project.
  2. Copy the Project ID.
  3. Add it to .env.local as NEXT_PUBLIC_WC_PROJECT_ID=... (the NEXT_PUBLIC_ prefix exposes it to the browser, which is required and safe for this ID).
  4. In the dashboard, set the project's allowed domains / origins to your production + localhost URLs, or connections will be rejected.

Required for: the WalletConnect modal, RainbowKit, ConnectKit, and any mobile wallet connection.

Quotas/pricing change — there is a free tier suitable for development and small dApps, with paid plans above it. Do not hardcode a specific message/MAU cap; confirm current limits and pricing at https://dashboard.reown.com and https://reown.com/pricing (as of Jun 2026).


13. Security Guardrails (money-moving — read before shipping)

A wallet integration signs and broadcasts transactions that move real, irreversible funds. Treat every signature request as adversarial input and every contract address as untrusted until verified. The rules below are non-negotiable for production.

Seed phrases & private keys

  • Never ask the user for their seed phrase, mnemonic, or private key — no legitimate dApp ever needs them. A field that requests them is a phishing pattern; remove it. The wallet (MetaMask, etc.) holds the key and signs locally.
  • Never put a private key in frontend code, env vars shipped to the browser, or logs. Server-side signers (relayers) keep keys in a KMS/HSM or a secret manager, never in NEXT_PUBLIC_*.

Before every write / signature, show and verify

  • Chain: confirm chainId is one you support and that the contract/token address is the correct one for that chain (addresses differ per chain — see the per-chain note in section 4).
  • Spender & amount: render the spender address (or verifyingContract) and the human-readable amount (via formatUnits) in your own UI. Don't rely solely on the wallet popup.
  • Simulate: run useSimulateContract / publicClient.simulateContract first so reverts surface before the popup (section 4).

Approvals (ERC-20 approve / Permit2)

  • Handle approvals as a separate, explicit step from the action that spends the allowance — never bundle an unlimited approval into an unrelated click.
  • Prefer exact-amount approvals over type(uint256).max (unlimited). If you must use unlimited for UX, say so and offer a one-click revoke.
  • A signed Permit/Permit2 is a gasless allowance — anyone can submit it. Show spender + value + token + deadline + chain, fetch the live nonce, and keep deadlines short.
// Allowance check → approve (exact amount) → spend. Approve only the shortfall.
import { useReadContract, useSimulateContract, useWriteContract, useAccount } from 'wagmi';
import { erc20Abi, parseUnits } from 'viem';
import { USDC_MAINNET, EXAMPLE_CONTRACT } from './addresses';

function ApproveThenAct({ amount }: { amount: bigint }) {
  const { address: owner } = useAccount();
  const { data: allowance } = useReadContract({
    address: USDC_MAINNET,
    abi: erc20Abi,
    functionName: 'allowance',
    args: owner ? [owner, EXAMPLE_CONTRACT] : undefined,
    query: { enabled: !!owner },
  });

  const needsApproval = allowance === undefined || allowance < amount;

  const { data: sim } = useSimulateContract({
    address: USDC_MAINNET,
    abi: erc20Abi,
    functionName: 'approve',
    args: [EXAMPLE_CONTRACT, amount], // exact amount, NOT 2**256-1
    query: { enabled: needsApproval },
  });
  const { writeContract, isPending } = useWriteContract();

  if (!needsApproval) return <p>Allowance OK — proceed.</p>;
  return (
    <button onClick={() => sim && writeContract(sim.request)} disabled={!sim || isPending}>
      {isPending ? 'Approving...' : `Approve ${amount} (exact)`}
    </button>
  );
}

Verifying contract addresses

  • Pin known addresses in a typed, per-chain constants file (the addresses.ts shown at the top of this skill); don't accept a token/contract address from a URL or untrusted API without an allowlist check.
  • For tokens, cross-check against a reputable token list (e.g. Uniswap's) before trusting metadata. Phishers deploy fake tokens with familiar symbols.

Blind signing

  • Warn (or block) when a wallet would sign opaque data the user can't read — raw personal_sign of a hash, or eth_sign (legacy, dangerous; many wallets disable it). Prefer EIP-712 typed data so the wallet can render fields.
  • For login, use Sign-In with Ethereum (EIP-4361 / SIWE) with a domain-bound, nonce'd message instead of signing an arbitrary string.

Other production hardening

  • RPC URLs in transports are public — use a provider key restricted by domain/origin, and a server-side proxy for anything sensitive. Rotate keys; never commit them.
  • Re-validate chainId and balances after the user switches networks mid-flow.
  • Reads from the chain are public data, but never trust client-supplied amounts/addresses on a backend; re-verify server-side before crediting anything.

This skill covers integration mechanics, not financial, legal, or tax advice. Handling user funds may trigger regulatory (KYC/AML), licensing, and tax obligations that vary by jurisdiction — consult a qualified professional before going to production.