Implementing Crypto Wallet Connection to Web Application
Wallet connection is the first entry point for any Web3 application. The user clicks "Connect Wallet," the browser opens MetaMask or WalletConnect QR, the application gets the address and signature. Sounds simple, but under the hood — three different protocols, a dozen wallet providers, and a number of state issues that need to be solved correctly from the start.
What Happens During Connection
- Browser checks for
window.ethereum(injected wallets: MetaMask, Rabby, Brave Wallet) - Application calls
eth_requestAccounts— confirmation popup appears - Wallet returns an array of addresses, the first is active
- Application signs SIWE (Sign-In with Ethereum) message for backend authentication
- Session is created on the server by signature
WalletConnect (mobile wallets) works differently: through relay server, WebSocket, and QR code. Coinbase Wallet supports both methods.
Minimal Implementation via ethers.js
// lib/wallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
export interface WalletState {
address: string | null;
chainId: number | null;
provider: BrowserProvider | null;
signer: JsonRpcSigner | null;
}
export async function connectWallet(): Promise<WalletState> {
if (!window.ethereum) {
throw new Error('No injected wallet found. Install MetaMask.');
}
const provider = new BrowserProvider(window.ethereum);
const accounts = await provider.send('eth_requestAccounts', []);
const network = await provider.getNetwork();
const signer = await provider.getSigner();
return {
address: accounts[0],
chainId: Number(network.chainId),
provider,
signer,
};
}
export async function switchChain(chainId: number): Promise<void> {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: `0x${chainId.toString(16)}` }],
});
}
Handling Wallet Events
Wallet changes account or network without notifying the application — need to subscribe to events:
// hooks/useWalletEvents.ts
import { useEffect } from 'react';
import { useWalletStore } from '@/store/wallet';
export function useWalletEvents() {
const { disconnect, setAddress, setChainId } = useWalletStore();
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
disconnect();
} else {
setAddress(accounts[0]);
}
};
const handleChainChanged = (chainIdHex: string) => {
setChainId(parseInt(chainIdHex, 16));
// Don't reload page — update state
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
window.ethereum.on('disconnect', disconnect);
return () => {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
window.ethereum.removeListener('disconnect', disconnect);
};
}, [disconnect, setAddress, setChainId]);
}
SIWE Authentication
A wallet address is not a user identifier by itself. It can be forged in an HTTP request. For backend session, you need a signature:
// lib/siwe.ts
import { SiweMessage } from 'siwe';
export async function signInWithEthereum(
address: string,
chainId: number,
signer: JsonRpcSigner,
): Promise<{ message: string; signature: string }> {
const nonce = await fetch('/api/auth/nonce').then(r => r.text());
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in to MyApp',
uri: window.location.origin,
version: '1',
chainId,
nonce,
});
const messageStr = message.prepareMessage();
const signature = await signer.signMessage(messageStr);
return { message: messageStr, signature };
}
Backend verifies the signature via siwe package (Node.js) or any ecrecover implementation. Nonce in Redis with TTL 5 minutes — protection against replay attacks.
Multiple Wallets via Universal Provider
To support MetaMask, WalletConnect, Coinbase Wallet without custom logic for each — use @web3-onboard or the wagmi + viem stack. Minimal example with @web3-onboard:
import Onboard from '@web3-onboard/core';
import injectedModule from '@web3-onboard/injected-wallets';
import walletConnectModule from '@web3-onboard/walletconnect';
const injected = injectedModule();
const walletConnect = walletConnectModule({
projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
requiredChains: [1, 137],
});
export const onboard = Onboard({
wallets: [injected, walletConnect],
chains: [
{ id: '0x1', token: 'ETH', label: 'Ethereum Mainnet', rpcUrl: process.env.ETH_RPC_URL! },
{ id: '0x89', token: 'MATIC', label: 'Polygon', rpcUrl: process.env.POLYGON_RPC_URL! },
],
appMetadata: {
name: 'MyApp',
icon: '/logo.svg',
description: 'DeFi platform',
},
});
Connection Persistence
After page reload, wallet state needs to be restored without repeated popup:
// Check on initialization
async function restoreConnection(): Promise<void> {
if (!window.ethereum) return;
// eth_accounts (not eth_requestAccounts) — doesn't call popup
const accounts: string[] = await window.ethereum.request({
method: 'eth_accounts',
});
if (accounts.length > 0) {
// Wallet already authorized — restore state
const provider = new BrowserProvider(window.ethereum);
const network = await provider.getNetwork();
walletStore.set({ address: accounts[0], chainId: Number(network.chainId) });
}
}
Timeline: basic MetaMask + WalletConnect connection with SIWE authentication — 2–3 days. Including event handling, persistence, and support for 3–4 wallets via @web3-onboard — 3–5 days.







