Error Handling
The KEA Wallet SDK throws standard JavaScript Error objects with an added .code property for programmatic handling. Errors come from two sources: promise rejections when calling SDK methods like connect() or signTransaction(), and background events emitted by the SDK when something goes wrong asynchronously (e.g., iframe communication failure or wallet lock).
This guide covers every error type the SDK can produce, how to catch them, and patterns for graceful recovery.
- You have installed the SDK packages — see Quickstart
- You know which package to use — see Integration Options
- You are familiar with SDK events — see SDK Events
The Error Object
Every error thrown by the SDK is a standard Error with an ErrorCode attached via the code property:
import { ErrorCode } from '@kea-wallet/browser-sdk';
try {
await connect({ metadata: { appName: 'My App' } });
} catch (err) {
const error = err as Error & { code?: string };
switch (error.code) {
case ErrorCode.USER_REJECTED:
// User closed the modal or clicked Reject
break;
case ErrorCode.TIMEOUT:
// Operation timed out
break;
default:
console.error('Unexpected error:', error.code, error.message);
}
}
Always use error.code for branching logic, never error.message. Error messages may change between SDK versions; error codes are stable.
Error Code Reference
The SDK defines 11 error codes in the ErrorCode constant, re-exported from all SDK packages (@kea-wallet/browser-sdk, @kea-wallet/react-sdk):
| Code | Description | When it occurs | Recommended UI action |
|---|---|---|---|
USER_REJECTED | User clicked Reject or closed the modal | connect(), signTransaction(), signMessage() | Dismiss silently or show a brief toast |
WALLET_LOCKED | Wallet is currently locked | Any operation while the wallet is locked | Show "Please unlock your wallet" prompt |
INVALID_PASSWORD | Wrong password entered | connect() with password authentication | Show "Invalid password" with retry option |
ALREADY_CONNECTED | Duplicate connection attempt | connect() when already connected | No-op — use the existing connection |
ACCOUNT_NOT_FOUND | Unknown public key | selectAccount() with an invalid address | Show "Account not found" |
INVALID_TRANSACTION | Malformed transaction payload | signTransaction() | Show "Invalid transaction" — this is a developer error |
TRANSACTION_FAILED | Transaction execution failed on-chain | signTransaction() | Show failure reason, suggest retry |
INSUFFICIENT_FUNDS | Not enough balance | signTransaction() | Show balance and required amount |
NETWORK_ERROR | Network connectivity issue | Any network operation | Show "Network error" with retry option |
TIMEOUT | Operation timed out | Any operation | Show timeout message with retry option |
UNKNOWN_ERROR | Catch-all for unexpected errors | Any operation | Show generic error message, log to error tracker |
Timeout Durations
| Operation | Timeout |
|---|---|
connect(), signTransaction(), signMessage() | 5 minutes |
Other SDK requests (e.g., selectAccount(), getAccounts()) | 30 seconds |
Iframe initialization (initialize()) | 10 seconds |
Session check (autoConnect) | 3 seconds |
Handling Connection Errors
The connect() method throws when the user rejects the connection, the wallet times out, or the iframe fails to load. Always wrap it in a try/catch:
- React
- Vanilla JS
import { useState } from 'react';
import { useWallet } from '@kea-wallet/react-sdk';
import { ErrorCode } from '@kea-wallet/browser-sdk';
function ConnectWithErrorHandling() {
const { connect, isConnecting } = useWallet();
const [error, setError] = useState<string | null>(null);
const handleConnect = async () => {
setError(null);
try {
await connect({ metadata: { appName: 'My App' } });
} catch (err) {
const error = err as Error & { code?: string };
switch (error.code) {
case ErrorCode.USER_REJECTED:
// User cancelled — not a real error
console.log('User cancelled the connection');
break;
case ErrorCode.ALREADY_CONNECTED:
// Already connected — safe to ignore
break;
case ErrorCode.TIMEOUT:
setError('Connection timed out. Please try again.');
break;
default:
setError(`Connection failed: ${error.message}`);
}
}
};
return (
<div>
<button onClick={handleConnect} disabled={isConnecting}>
{isConnecting ? 'Connecting...' : 'Connect Wallet'}
</button>
{error && <p className="error">{error}</p>}
</div>
);
}
import { BrowserSDK, ErrorCode } from '@kea-wallet/browser-sdk';
const sdk = new BrowserSDK({
rpcUrl: 'https://grpc-web.alphanet.thruput.org',
autoConnect: true,
});
await sdk.initialize();
connectButton.addEventListener('click', async () => {
try {
const result = await sdk.connect({
metadata: { appName: 'My App' },
});
console.log('Connected:', result.accounts.length, 'accounts');
} catch (err) {
const error = err as Error & { code?: string };
switch (error.code) {
case ErrorCode.USER_REJECTED:
console.log('User cancelled');
break;
case ErrorCode.TIMEOUT:
statusEl.textContent = 'Connection timed out. Please try again.';
break;
default:
statusEl.textContent = `Connection failed: ${error.message}`;
}
}
});
Key points:
connect()throws on user rejection — always wrap in try/catch or the promise rejection will be unhandled.USER_REJECTEDis expected behavior — the user chose to cancel. Don't show alarming error UI for this.- Concurrent calls are deduplicated — if
connect()is already in progress, a second call joins the same request rather than opening a second modal. connect()must be triggered by a user gesture (click/tap) — browsers may block the modal overlay if called programmatically on page load.
Handling Transaction Errors
Signing operations (signTransaction() and signMessage()) can fail for additional reasons: the wallet may not be connected, the payload may be malformed, or the user may reject the signing request.
- React
- Vanilla JS
import { useWallet, useKea } from '@kea-wallet/react-sdk';
import { ErrorCode } from '@kea-wallet/browser-sdk';
function SignWithErrorHandling() {
const { isConnected } = useWallet();
const { sdk } = useKea();
const handleSign = async () => {
if (!isConnected || !sdk) {
console.error('Wallet not connected');
return;
}
try {
const result = await sdk.thru.signTransaction(base64Payload);
console.log('Signed:', result.signedTransaction);
} catch (err) {
const error = err as Error & { code?: string };
switch (error.code) {
case ErrorCode.USER_REJECTED:
console.log('User declined to sign');
break;
case ErrorCode.INVALID_TRANSACTION:
console.error('Invalid transaction format');
break;
case ErrorCode.INSUFFICIENT_FUNDS:
console.error('Not enough balance');
break;
case ErrorCode.TRANSACTION_FAILED:
console.error('Transaction failed on-chain:', error.message);
break;
default:
console.error('Signing failed:', error.message);
}
}
};
}
import { BrowserSDK, ErrorCode } from '@kea-wallet/browser-sdk';
// Guard: always check connection before signing
if (!sdk.getSelectedAccount()) {
console.error('Wallet not connected');
return;
}
try {
const result = await sdk.thru.signTransaction(base64Payload);
console.log('Signed:', result.signedTransaction);
} catch (err) {
const error = err as Error & { code?: string };
switch (error.code) {
case ErrorCode.USER_REJECTED:
console.log('User declined to sign');
break;
case ErrorCode.INVALID_TRANSACTION:
console.error('Invalid transaction format');
break;
case ErrorCode.INSUFFICIENT_FUNDS:
console.error('Not enough balance');
break;
default:
console.error('Signing failed:', error.message);
}
}
Key points:
- Always check
isConnectedbefore signing — callingsignTransaction()without a connection throws a generic error. - Transaction payloads must be base64-encoded strings. Invalid encoding triggers
INVALID_TRANSACTION. signMessage()accepts astringornumber[](byte array) — no base64 encoding required for messages.- The user can reject signing just like connection — same
USER_REJECTEDcode. - Signing operations have a 5-minute timeout before throwing
TIMEOUT.
Error & Disconnect Events
Some errors happen in the background — not as a result of calling an SDK method. The SDK emits events for these cases. You should subscribe to them to keep your UI in sync.
Three Event Types
- React
- Vanilla JS
import { useEffect } from 'react';
import { useKea } from '@kea-wallet/react-sdk';
function ErrorEventListener() {
const { sdk } = useKea();
useEffect(() => {
if (!sdk) return;
const onError = (err: Error) => {
console.error('Unrecoverable SDK error:', err.message);
// Show a "wallet unavailable" UI
};
const onLock = ({ reason }: { reason?: string }) => {
console.log('Wallet locked:', reason);
// A disconnect event will follow automatically
};
const onDisconnect = ({ reason }: { reason?: string }) => {
if (reason === 'locked') {
console.log('Disconnected because wallet was locked');
} else if (reason === 'disconnected_in_other_tab') {
console.log('Disconnected from another tab');
} else {
console.log('User disconnected');
}
};
sdk.on('error', onError as never);
sdk.on('lock', onLock as never);
sdk.on('disconnect', onDisconnect as never);
return () => {
sdk.off('error', onError as never);
sdk.off('lock', onLock as never);
sdk.off('disconnect', onDisconnect as never);
};
}, [sdk]);
}
// Subscribe to background error events
sdk.on('error', (err) => {
console.error('Unrecoverable error:', err.message);
// Show a "wallet unavailable" fallback UI
});
sdk.on('lock', ({ reason }) => {
console.log('Wallet locked:', reason);
// A disconnect event will follow automatically
});
sdk.on('disconnect', ({ reason }) => {
if (reason === 'locked') {
statusEl.textContent = 'Wallet locked. Please unlock and reconnect.';
} else if (reason === 'disconnected_in_other_tab') {
statusEl.textContent = 'Disconnected from another tab.';
} else {
statusEl.textContent = 'Wallet disconnected.';
}
});
The Lock → Disconnect Cascade
When the wallet is locked (by the user or due to inactivity), two events fire in quick succession:
lockfires with{ reason?: string }disconnectfires immediately after withreason: "locked"
Listen to disconnect for general session-end cleanup. Only listen to lock if you need a specific "Wallet Locked" UI — otherwise disconnect covers both cases.
Disconnect Reasons
| Reason | Trigger | Typical action |
|---|---|---|
"locked" | Wallet was locked (preceded by a lock event) | Show "Wallet locked" banner with reconnect option |
"disconnected_in_other_tab" | User disconnected in another browser tab | Informational banner — user chose to disconnect elsewhere |
undefined | Explicit disconnect() call | Clean state transition, no banner needed |
React Error Handling
In React apps, KeaProvider handles SDK event subscriptions internally. However, there are two independent error sources you need to handle:
useKea().error— Unrecoverable SDK errors fromKeaProvider's internal event listeners (theerrorevent). These represent infrastructure failures like iframe communication breakdowns.- Promise rejections from
connect()— User-facing errors like rejection and timeouts. These must be caught via try/catch in your component.
useKea().error and connect() rejections are independent. A try/catch around connect() does not populate useKea().error, and useKea().error does not capture connect() rejections.
State Priority Rendering
When building your UI, check states in this order to show the most relevant information:
isReconnecting → isConnecting → error → connectError → isConnected → default
import { useState } from 'react';
import { useWallet, useKea } from '@kea-wallet/react-sdk';
function WalletStatus() {
const { connect, disconnect, isConnected, isConnecting, isReconnecting } = useWallet();
const { error } = useKea();
const [connectError, setConnectError] = useState<Error | null>(null);
const handleConnect = async () => {
setConnectError(null);
try {
await connect({ metadata: { appName: 'My App' } });
} catch (err) {
setConnectError(err as Error);
}
};
// Priority-based rendering
if (isReconnecting) return <div>Restoring session...</div>;
if (isConnecting) return <div>Connecting...</div>;
if (error) return <div>SDK Error: {error.message}</div>;
if (connectError) return <div>Connection failed: {connectError.message}</div>;
if (isConnected) return <button onClick={() => disconnect()}>Disconnect</button>;
return <button onClick={handleConnect}>Connect Wallet</button>;
}
Key points:
- Check
isReconnectingbeforeisConnecting— this distinguishes silent auto-reconnect from user-initiated connection, avoiding a brief flash of the connect button on page load. useWallet()does not expose anerrorfield — useuseKea()for SDK-level errors.- For manual event subscriptions in React, always clean up in
useEffect's return function to prevent memory leaks and stale closures. See the SDK Events guide for details.
Recovery Patterns
Retry with Exponential Backoff
For transient failures (timeouts, network errors), retry the connection with increasing delays. Never retry USER_REJECTED — the user intentionally cancelled.
- React
- Vanilla JS
import { useWallet } from '@kea-wallet/react-sdk';
import { ErrorCode } from '@kea-wallet/browser-sdk';
async function connectWithRetry(
connect: ReturnType<typeof useWallet>['connect'],
maxAttempts = 3,
) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await connect({ metadata: { appName: 'My App' } });
} catch (err) {
const error = err as Error & { code?: string };
// Never retry user rejections
if (error.code === ErrorCode.USER_REJECTED) {
throw error;
}
if (attempt === maxAttempts) throw error;
// Exponential backoff: 1s, 2s, 3s
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
}
import { ErrorCode } from '@kea-wallet/browser-sdk';
async function connectWithRetry(sdk, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await sdk.connect({ metadata: { appName: 'My App' } });
} catch (err) {
// Never retry user rejections
if (err.code === ErrorCode.USER_REJECTED) {
throw err;
}
if (attempt === maxAttempts) throw err;
// Exponential backoff: 1s, 2s, 3s
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
}
Graceful Degradation
Design your UI to work at three levels: full access when connected, read-only when disconnected, and a fallback when the SDK itself has an error:
import { useWallet, useKea } from '@kea-wallet/react-sdk';
function AppContent() {
const { isConnected, isReconnecting, connect } = useWallet();
const { error } = useKea();
if (error) {
return (
<div>
<p>Wallet unavailable. Some features are disabled.</p>
{/* Show read-only public data */}
</div>
);
}
if (isConnected) {
return (
<div>
{/* Full access: signing, transactions, account info */}
</div>
);
}
return (
<div>
{/* Read-only mode: public data, connect prompt */}
<button
onClick={() => connect({ metadata: { appName: 'My App' } })}
disabled={isReconnecting}
>
{isReconnecting ? 'Restoring session...' : 'Connect for full access'}
</button>
</div>
);
}
Reconnect After Disconnect
Show a contextual banner when the wallet disconnects, with a reconnect option. The disconnect reason tells you what happened:
- React
- Vanilla JS
import { useEffect, useRef, useState } from 'react';
import { useWallet, useKea } from '@kea-wallet/react-sdk';
function DisconnectBanner() {
const { isConnected, connect } = useWallet();
const { sdk } = useKea();
const wasConnected = useRef(false);
const [banner, setBanner] = useState<string | null>(null);
useEffect(() => {
if (isConnected) {
wasConnected.current = true;
setBanner(null);
} else if (wasConnected.current) {
wasConnected.current = false;
}
}, [isConnected]);
useEffect(() => {
if (!sdk) return;
const onDisconnect = ({ reason }: { reason?: string }) => {
if (reason === 'locked') {
setBanner('Wallet locked. Please unlock and reconnect.');
} else if (reason === 'disconnected_in_other_tab') {
setBanner('Disconnected from another tab.');
} else {
setBanner('Wallet disconnected.');
}
};
sdk.on('disconnect', onDisconnect as never);
return () => { sdk.off('disconnect', onDisconnect as never); };
}, [sdk]);
if (!banner) return null;
return (
<div className="banner">
<span>{banner}</span>
<button onClick={() => connect({ metadata: { appName: 'My App' } })}>
Reconnect
</button>
<button onClick={() => setBanner(null)}>Dismiss</button>
</div>
);
}
let wasConnected = false;
sdk.on('connect', () => {
wasConnected = true;
bannerEl.hidden = true;
});
sdk.on('disconnect', ({ reason }) => {
if (!wasConnected) return;
wasConnected = false;
if (reason === 'locked') {
bannerEl.textContent = 'Wallet locked. Please unlock and reconnect.';
} else if (reason === 'disconnected_in_other_tab') {
bannerEl.textContent = 'Disconnected from another tab.';
} else {
bannerEl.textContent = 'Wallet disconnected.';
}
bannerEl.hidden = false;
reconnectBtn.onclick = () => {
sdk.connect({ metadata: { appName: 'My App' } }).catch(console.error);
};
});
The SDK's autoConnect: true already handles session restoration on page reload — you don't need to implement reconnection for that case. The patterns above are for mid-session disconnects (lock, cross-tab disconnect).
UX Best Practices
- Don't alarm users on
USER_REJECTED— it's normal user behavior. Dismiss silently or show a brief, neutral toast. - Show actionable error messages — tell users what to do, not just what went wrong. "Please unlock your wallet" is better than "WALLET_LOCKED error".
- Provide a cancel/retry option for long operations — the 5-minute timeout for
connect()and signing is generous. Don't block the UI with a spinner the entire time. - Log
UNKNOWN_ERRORto an error tracker — these usually indicate edge cases in SDK–iframe communication worth investigating. Consider sending them to Sentry or a similar service. - Clean up event listeners — in vanilla JS, always call
sdk.off()when done to prevent memory leaks and duplicate notifications. Store the exact handler reference — anonymous functions cannot be unsubscribed. - Handle the lock event — a locked wallet is effectively disconnected. Don't leave the UI in a "connected" state when the wallet can no longer sign.
What's Next
- Connect Wallet — Connection flow and session persistence
- SDK Events — Full event reference and subscription patterns
- Sign Transactions — Build and sign transactions with the active account
- BrowserSDK Reference — Full API reference for all methods and events