Коректна обробка помилок гаманця — це різниця між застосунком, який зрозуміло пояснює, що сталося, і таким, що просто зависає з білим екраном. Нижче наведена системна класифікація помилок і конкретні патерни їх обробки для Solana-frontend на базі Wallet Adapter.
Середовище: Node.js 20.x, React 18.x, @solana/web3.js 1.95.x, @solana/wallet-adapter-react 0.15.x, @solana/wallet-adapter-react-ui 0.9.x
Кластер: Devnet
Передумови: налаштований WalletProvider у дереві компонентів, встановлений гаманець (наприклад, Phantom) у браузері
Очікуваний результат: застосунок перехоплює кожну помилку гаманця та показує користувачу зрозуміле повідомлення з конкретною пропозицією дії
Типи помилок гаманця
Усі помилки, пов'язані з гаманцем у Solana-frontend, діляться на дві великі групи: помилки взаємодії з користувачем та помилки мережі й транзакцій. Перша група виникає на етапі підключення або підпису, друга — після відправки транзакції в кластер.
Відхилення підключення (user rejected)
Користувач натискає кнопку підключення, гаманець відкриває спливаюче вікно, але натискає «Скасувати» або закриває його. Wallet Adapter викидає помилку з інформацією про відхилення. Різні гаманці передають це по-різному: Phantom використовує код 4001, інші гаманці можуть мати інші коди або взагалі не передавати код, лише текстове повідомлення.
Відхилення підпису (transaction rejected)
Гаманець підключено, але коли застосунок просить підписати транзакцію, користувач відхиляє запит у спливаючому вікні гаманця. Це окремий тип помилки, який відрізняється від відхилення підключення, хоча механічно схожий. Важливо не плутати відхилення підпису з помилкою самої транзакції після відправки.
Обробка помилок підключення
try/catch навколо wallet.connect()
Метод connect() з хука useWallet повертає Promise, який може завершитися помилкою. Обов'язково обгорніть його в try/catch і перевіряйте тип помилки.
Концептуальний приклад:
Цей приклад демонструє механіку обробки. Для production використовуйте централізований обробник помилок або toast-бібліотеку (наприклад, react-hot-toast), а не alert.
import { useWallet, WalletNotSelectedError, WalletNotFoundError } from '@solana/wallet-adapter-react';
function ConnectButton() {
const { select, connect, wallet } = useWallet();
const handleConnect = async () => {
if (!wallet) {
select('phantom');
return;
}
try {
await connect();
} catch (error) {
if (error instanceof WalletNotSelectedError) {
alert('Гаманець не обрано. Спробуйте ще раз.');
} else if (error instanceof WalletNotFoundError) {
alert('Гаманець не знайдено. Встановіть розширення гаманця.');
} else if (
error instanceof Error &&
(error.message.includes('User rejected') || 'code' in error && (error as any).code === 4001)
) {
alert('Ви відхилили запит на підключення.');
} else {
alert('Не вдалося підключити гаманець: ' + error.message);
}
}
};
return <button onClick={handleConnect}>Підключити гаманець</button>;
}
Перевірка на Devnet: натисніть кнопку, закрийте спливаюче вікно Phantom — має з'явитися повідомлення «Ви відхилили запит на підключення», а не необработана помилка в консолі.
Повідомлення користувачу з пропозицією дій
Кожне повідомлення про помилку має містити пропозицію дії. Ніяких «Сталася помилка» без подальших кроків. Карта відповідей:
| Ситуація | Повідомлення | Дія |
|---|---|---|
| Гаманець не встановлено | «Гаманець не знайдено» | Кнопка «Встановити Phantom» (посилання на офіційний сайт гаманця) |
| Користувач відхилив підключення | «Ви скасували підключення» | Кнопка «Спробувати знову» |
| Гаманець не підтримується | «Цей гаманець не підтримується» | Кнопка «Обрати інший гаманець» |
| Невідома помилка | «Не вдалося підключити: [текст помилки]» | Кнопка «Спробувати знову» + текст «Перевірте, чи відкритий гаманець» |
Обробка помилок підпису
Перехоплення помилки при signTransaction
Коли застосунок викликає signTransaction або використовує sendTransaction (який внутрішно підписує транзакцію), гаманець показує спливаюче вікно з деталями транзакції. Користувач може відхилити підпис. Це окрема помилка, яка не є помилкою мережі.
import { useWallet } from '@solana/wallet-adapter-react';
import { Connection, PublicKey, Transaction, SystemProgram, LAMPORTS_PER_SOL } from '@solana/web3.js';
const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
function TransferButton({ toAddress }: { toAddress: string }) {
const { publicKey, sendTransaction } = useWallet();
const handleTransfer = async () => {
if (!publicKey) return;
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: publicKey,
toPubkey: new PublicKey(toAddress),
lamports: 0.001 * LAMPORTS_PER_SOL,
})
);
try {
const signature = await sendTransaction(transaction, connection);
await connection.confirmTransaction(signature, 'confirmed');
} catch (error) {
if (
error instanceof Error &&
(error.message.includes('User rejected') || 'code' in error && (error as any).code === 4001)
) {
alert('Ви відхилили підпис транзакції. Транзакція не відправлена.');
} else {
alert('Помилка підпису: ' + error.message);
}
}
};
return <button onClick={handleTransfer}>Відправити 0.001 SOL</button>;
}
Перевірка на Devnet: натисніть кнопку, у вікні Phantom натисніть «Cancel» — має з'явитися повідомлення про відхилення підпису, а не помилка в консолі браузера.
Повторна спроба підпису
Якщо користувач випадково відхилив підпис, логічно запропонувати повторну спробу. Проте не робіть це автоматично — повторний виклик sendTransaction без дії користувача знову відкриє спливаюче вікно гаманця, що є поганим досвідом. Замість цього покажіть кнопку «Підписати знову», яка викличе ту саму функцію.
Обмеження: не зберігайте підписану транзакцію для повторних спроб. У Solana підписана транзакція містить блок recent_blockhash, який діє обмежений час. Повторна відправка тієї ж підписаної транзакції пізніше може бути відхилена кластером як застаріла. Завжди створюйте нову транзакцію перед повторною спробою.
Обробка помилок транзакції
Після успішного підпису транзакція відправляється в кластер. Тут виникають помилки іншої природи — вони пов'язані з мережею, а не з діями користувача в гаманці.
Insufficient funds — повідомлення про баланс
Якщо на рахунку недостатньо SOL для оплати комісії та суми переказу, RPC-вузол поверне помилку. У @solana/web3.js вона приходить як SendTransactionError з відповідним повідомленням у логах.
import { SendTransactionError } from '@solana/web3.js';
try {
const signature = await sendTransaction(transaction, connection);
await connection.confirmTransaction(signature, 'confirmed');
} catch (error) {
if (error instanceof SendTransactionError) {
const logs = error.logs || [];
const insufficientFunds = logs.some(
(log) => log.includes('InsufficientFunds') || log.includes('insufficient lamports')
);
if (insufficientFunds) {
alert('Недостатньо SOL на рахунку. Потрібно: X SOL, доступно: Y SOL.');
return;
}
alert('Транзакцію відхилено кластером: ' + logs.join('\n'));
} else if (
error instanceof Error &&
(error.message.includes('User rejected') || 'code' in error && (error as any).code === 4001)
) {
alert('Ви відхилили підпис транзакції.');
} else {
alert('Невідома помилка: ' + error.message);
}
}
Важливо: для точного визначення суми, якої не вистачає, потрібно окремо запитати баланс через connection.getBalance() і порівняти з розрахованою вартістю транзакції. Це окрема тема, яка розкривається у матеріалі про перевірку балансу.
Таймаут — пропозиція повторити
Tранзакція може не отримати підтвердження протягом очікуваного часу. Це стається через перевантаження кластера або проблеми з RPC-вузлом. У такому разі confirmTransaction викидає помилку таймауту.
try {
const signature = await sendTransaction(transaction, connection);
await connection.confirmTransaction(signature, 'confirmed');
alert('Транзакція підтверджена: ' + signature);
} catch (error) {
if (error instanceof Error && error.message.includes('timed out')) {
alert('Транзакція не підтверджена вчасно. Можливо, вона все ще обробляється. Перевірте статус у експлорері та спробуйте знову, якщо потрібно.');
} else {
// інша обробка, як у попередніх прикладах
}
}
Ризик: транзакція могла бути включена в блок після таймауту на стороні вашого застосунку, але до того, як користувач натиснув «Спробувати знову». Повторна відправка тієї ж транзакції (з тим самим recent_blockhash) безпечна — кластер відхилить дублікат. Але якщо ви створюєте нову транзакцію, є ризик подвійного списання. У production-рішеннях для цього використовують механізми deduplication або перевірку історії транзакцій перед повторною відправкою.
Типові помилки
Необроблені promise rejection
Найчастіша помилка початківців — виклик connect() або sendTransaction() без try/catch. У браузері це призводить до необробленого rejection, який з'являється в консолі як Unhandled Promise Rejection, а користувач не бачить жодного повідомлення. Застосунок здається «завислим».
Правило: кожен виклик методу гаманця, що повертає Promise, має бути обгорнутий у try/catch. Жодних винятків. Навіть якщо ви «просто тестуєте».
Занадто загальне повідомлення про помилку
Конструкція на кшталт catch (e) { alert('Помилка: ' + e.message) } показує користувачу технічний текст, який він не може зрозуміти. Наприклад: «Blockhash not found» або «Transaction simulation failed: custom program error: 0x1». Це не допомагає користувачу прийняти рішення.
Правило: розрізняйте мінімум три категорії: відхилення користувачем, помилка балансу/комісії, помилка кластера. Для кожної категорії — своє повідомлення і своя пропозиція дії. Усі інші помилки показуйте з текстом «Сталася неочікувана помилка. Спробуйте пізніше або зверніться до підтримки», а технічні деталі логуйте в консоль для дебагу.
catch (error) {
// Логування для розробника
console.error('Wallet error:', error);
// Зрозуміле повідомлення для користувача
if (isUserRejection(error)) {
showToast('Ви скасували дію.');
} else if (isInsufficientFunds(error)) {
showToast('Недостатньо SOL для цієї транзакції.');
} else if (isTimeout(error)) {
showToast('Мережа перевантажена. Спробуйте через хвилину.');
} else {
showToast('Щось пішло не так. Спробуйте пізніше.');
}
}
Де допоміжні функції виглядають так:
function isUserRejection(error: unknown): boolean {
if (error instanceof Error) {
if (error.message.includes('User rejected')) return true;
if ('code' in error && (error as any).code === 4001) return true;
}
return false;
}
function isInsufficientFunds(error: unknown): boolean {
if (error instanceof SendTransactionError && error.logs) {
return error.logs.some(
(log) => log.includes('InsufficientFunds') || log.includes('insufficient lamports')
);
}
return false;
}
function isTimeout(error: unknown): boolean {
return error instanceof Error && error.message.includes('timed out');
}
Наступний крок: після того, як обробка помилок гаманця працює стабільно, варто навчитися запитувати баланс рахунку перед формуванням транзакції — це дозволить показувати точну суму, якої не вистачає, ще до спроби відправки.