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
useAccounttouseConnectionand hook action functions tomutate/mutateAsync, and makes connector SDKs optional peer deps), but RainbowKit and ConnectKit still peer-require wagmi 2.x, so this skill targets wagmi v2: installwagmi@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 amountedflag (see the Custom Connect Button below — it already checksmounted) 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+initialStatepattern 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) andparseUnits('1.5', decimals)(display string → bigint) are the only correct way to convert token amounts.formatEther/parseEtherare the 18-decimal shorthands. Do arithmetic on the rawbigint, 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_MAINNETis only valid on chainId 1 — sending it while connected to Polygon either reverts or hits an unrelated contract. Always gate writes onchainIdand 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
Permitsignature is a gasless approval — once signed, anyone can submit it to grantspenderan allowance. Always renderspender,value, the token name, and chain in your UI before callingsignTypedData, fetch the livenoncefrom the contract, and prefer a finitevalueovertype(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:
- Sign in at https://dashboard.reown.com and create a new project.
- Copy the Project ID.
- Add it to
.env.localasNEXT_PUBLIC_WC_PROJECT_ID=...(theNEXT_PUBLIC_prefix exposes it to the browser, which is required and safe for this ID). - 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
chainIdis 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 (viaformatUnits) in your own UI. Don't rely solely on the wallet popup. - Simulate: run
useSimulateContract/publicClient.simulateContractfirst 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.tsshown 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_signof a hash, oreth_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
transportsare public — use a provider key restricted by domain/origin, and a server-side proxy for anything sensitive. Rotate keys; never commit them. - Re-validate
chainIdand 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.