Реализация подключения криптокошелька к веб-приложению
Подключение кошелька — первая точка входа в любое Web3-приложение. Пользователь нажимает «Connect Wallet», браузер открывает MetaMask или WalletConnect QR, приложение получает адрес и подпись. Звучит просто, но под капотом — три разных протокола, десяток поставщиков кошельков и ряд проблем с состоянием, которые нужно решать правильно с самого начала.
Что происходит при подключении
- Браузер проверяет наличие
window.ethereum(инжектированные кошельки: MetaMask, Rabby, Brave Wallet) - Приложение вызывает
eth_requestAccounts— появляется popup подтверждения - Кошелёк возвращает массив адресов, первый — активный
- Приложение подписывает SIWE (Sign-In with Ethereum) сообщение для аутентификации на бэкенде
- Сессия создаётся на сервере по подписи
WalletConnect (мобильные кошельки) работает иначе: через relay-сервер, WebSocket и QR-код. Coinbase Wallet поддерживает оба способа.
Минимальная реализация через 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)}` }],
});
}
Обработка событий кошелька
Кошелёк меняет аккаунт или сеть без уведомления приложения — нужно подписаться на события:
// 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));
// Страницу не перезагружаем — обновляем состояние
};
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-аутентификация
Адрес кошелька — не идентификатор пользователя сам по себе. Его можно подделать в HTTP-запросе. Для бэкенд-сессии нужна подпись:
// 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 };
}
Бэкенд верифицирует подпись через siwe пакет (Node.js) или любую реализацию ecrecover. Nonce в Redis с TTL 5 минут — защита от replay-атак.
Множество кошельков через универсальный провайдер
Для поддержки MetaMask, WalletConnect, Coinbase Wallet без custom logic для каждого — используется @web3-onboard или стек wagmi + viem. Минимальный пример с @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',
},
});
Персистентность подключения
После перезагрузки страницы состояние кошелька нужно восстанавливать без повторного popup:
// Проверка при инициализации
async function restoreConnection(): Promise<void> {
if (!window.ethereum) return;
// eth_accounts (не eth_requestAccounts) — не вызывает popup
const accounts: string[] = await window.ethereum.request({
method: 'eth_accounts',
});
if (accounts.length > 0) {
// Кошелёк уже авторизован — восстанавливаем состояние
const provider = new BrowserProvider(window.ethereum);
const network = await provider.getNetwork();
walletStore.set({ address: accounts[0], chainId: Number(network.chainId) });
}
}
Срок реализации: базовое подключение MetaMask + WalletConnect с SIWE-аутентификацией — 2–3 дня. Включая обработку событий, персистентность и поддержку 3–4 кошельков через @web3-onboard — 3–5 дней.







