The SDK ships three reference wallet adapters: ViemWalletAdapter (EVM),
SolanaWalletAdapter (@solana/wallet-adapter) and FreighterWalletAdapter
(Stellar). Each exposes signMessage() and getAddress(), which is all
deriveStealthKeysFromWallet() needs.
The wallets behind them report the same problems in different ways. A user who
declines a signature produces EIP-1193 code 4001 through viem, a
WalletSignMessageError wrapping 4001 through Phantom, and a
{ code: -4 } result from Freighter. This guide covers the tools that make
them look the same:
- a shared error taxonomy,
WraithWalletError, andnormalizeWalletError()to map provider errors onto it watchWalletEvents(), which reports account, network and disconnect changes in one shapegetNetwork()andassertWalletNetwork()for wrong-network checks- a reference of how each adapter behaves, covered by
test/wallet/conformance.test.ts
Everything here is opt-in. The adapters' signMessage() and getAddress()
still throw the same errors they always have.
import {
createViemWalletAdapter,
deriveStealthKeysFromWallet,
withNormalizedWalletErrors,
WalletNotConnectedError,
WalletUnavailableError,
WalletUserRejectedError,
WalletWrongNetworkError,
} from '@wraith-protocol/sdk';
const wallet = withNormalizedWalletErrors(createViemWalletAdapter(walletClient));
try {
const keys = await deriveStealthKeysFromWallet(wallet);
} catch (error) {
if (error instanceof WalletUserRejectedError) {
// The user cancelled. Offer a "Try again" button; don't retry automatically.
} else if (error instanceof WalletNotConnectedError) {
// Show "Connect wallet".
} else if (error instanceof WalletWrongNetworkError) {
// Ask the user to switch networks.
} else if (error instanceof WalletUnavailableError) {
// Link to the wallet's install page, or suggest another wallet.
} else {
throw error; // e.g. InvalidSignatureError from key derivation
}
}withNormalizedWalletErrors() only converts errors thrown by the wallet.
Errors from key derivation, such as InvalidSignatureError, pass through
unchanged.
All wallet errors extend WraithWalletError, which extends WraithError, so
code, context, docsLink, toJSON() and describe() work as described in
errors.md.
| Error Class | Stable Code | Meaning |
|---|---|---|
WalletNotConnectedError |
"WRAITH/WALLET/NOT_CONNECTED" |
The wallet is not connected, is locked, has not authorised this site, or disconnected. |
WalletUserRejectedError |
"WRAITH/WALLET/USER_REJECTED" |
The user declined the request or closed the wallet window. |
WalletWrongNetworkError |
"WRAITH/WALLET/WRONG_NETWORK" |
The wallet is on a different network than the request needs, or does not know the requested chain. |
WalletUnavailableError |
"WRAITH/WALLET/UNAVAILABLE" |
No usable wallet: not installed, not ready in this environment, or unable to do what was asked (such as sign messages). |
WalletRequestFailedError |
"WRAITH/WALLET/REQUEST_FAILED" |
Anything else the wallet reported. The provider's code and message are kept so you can inspect them. |
Every wallet error's context holds:
chain:'evm','solana'or'stellar', when knownreason: the provider's message, or the SDK's when the SDK found the problemproviderCode: the provider's code (such as4001or-4) or error name (such as'WalletNotReadyError')expectedNetworkandactualNetwork, onWalletWrongNetworkError
The original provider error is available as error.cause. It is not included
in toJSON(), because provider errors are not always serialisable.
const stellar = withNormalizedWalletErrors(new FreighterWalletAdapter(freighter));The wrapper returns a new adapter whose signMessage(), getAddress() and,
when the adapter has one, getNetwork() reject with WraithWalletErrors. The
adapter you passed in is not modified.
Use normalizeWalletError(error, chain?) for wallet calls you make yourself,
such as a "Connect" button:
import { normalizeWalletError } from '@wraith-protocol/sdk';
// viem
await walletClient.requestAddresses().catch((e) => {
throw normalizeWalletError(e, 'evm');
});
// @solana/wallet-adapter
await adapter.connect().catch((e) => {
throw normalizeWalletError(e, 'solana');
});
// Freighter returns errors instead of throwing them
const { error } = await requestAccess();
if (error) throw normalizeWalletError(error, 'stellar');normalizeWalletError() always returns a WraithWalletError. It checks the
error, then its cause chain and the error field that Solana wallet-adapter
uses to hold the wallet's own error. If it finds a WraithWalletError along
the way it returns that error unchanged, so normalising twice is safe.
viem and EIP-1193 wallets
| Provider error | Wraith error |
|---|---|
4001 User Rejected Request (viem UserRejectedRequestError) |
WalletUserRejectedError |
5000 CAIP-25 user rejection (WalletConnect) |
WalletUserRejectedError |
4100 Unauthorized (viem UnauthorizedProviderError) |
WalletNotConnectedError |
4900 Disconnected (viem ProviderDisconnectedError) |
WalletNotConnectedError |
viem AccountNotFoundError (signing with no account) |
WalletNotConnectedError |
4901 Chain Disconnected (viem ChainDisconnectedError) |
WalletWrongNetworkError |
4902 unrecognised chain from wallet_switchEthereumChain (viem SwitchChainError) |
WalletWrongNetworkError |
5710 unsupported chain ID (viem UnsupportedChainIdError) |
WalletWrongNetworkError |
viem ChainMismatchError |
WalletWrongNetworkError |
4200 Unsupported Method (viem UnsupportedProviderMethodError) |
WalletUnavailableError |
4901 means the wallet is connected, but not to the chain the request needs,
so it maps to a wrong network rather than a disconnect.
@solana/wallet-adapter
| Provider error | Wraith error |
|---|---|
WalletNotConnectedError, WalletDisconnectedError |
WalletNotConnectedError |
WalletNotSelectedError (@solana/wallet-adapter-react) |
WalletNotConnectedError |
WalletNotReadyError (wallet not installed or loadable) |
WalletUnavailableError |
WalletWindowClosedError |
WalletUserRejectedError |
WalletSignMessageError, WalletConnectionError and other wrappers |
Mapped from the wallet's error in .error |
Phantom reports 4001, 4100 and 4900 with the same meanings as EIP-1193,
so a declined Phantom signature (a WalletSignMessageError wrapping 4001)
becomes WalletUserRejectedError.
Freighter (@stellar/freighter-api 3 and later)
| Provider result | Wraith error |
|---|---|
{ code: -4, message: 'The user rejected this request.' }, also sent when the popup is closed |
WalletUserRejectedError |
{ code: -1, message: 'Node environment is not supported' } |
WalletUnavailableError |
| Empty address (the site has no access) | WalletNotConnectedError |
{ code: -1 } internal error |
WalletRequestFailedError |
FreighterWalletAdapter turns Freighter's error objects into plain Errors
carrying Freighter's message, so these messages are matched exactly.
Errors from the SDK's own adapters
| Error | Wraith error |
|---|---|
The viem wallet client has no connected account. |
WalletNotConnectedError |
The Solana wallet is not connected. |
WalletNotConnectedError |
Freighter is not connected. |
WalletNotConnectedError |
The constructors' TypeError for a wallet without signMessage (and getAddress for Freighter) |
WalletUnavailableError |
Everything else becomes WalletRequestFailedError with providerCode and
reason set. For example, MetaMask and Phantom use -32002 when a request is
already waiting for the user's approval; you can check
error.context.providerCode === -32002 and ask the user to open their wallet.
watchWalletEvents(source, listener) subscribes to a provider and returns a
function that unsubscribes.
| Chain | Source | Where it comes from |
|---|---|---|
evm |
{ chain: 'evm', provider } |
The EIP-1193 provider you gave viem's custom() transport, e.g. window.ethereum. A viem WalletClient does not expose events. |
solana |
{ chain: 'solana', wallet } |
The @solana/wallet-adapter adapter, e.g. useWallet().wallet?.adapter. |
stellar |
{ chain: 'stellar', watcher } |
new WatchWalletChanges(intervalMs) from @stellar/freighter-api. |
import { WatchWalletChanges } from '@stellar/freighter-api';
import { watchWalletEvents } from '@wraith-protocol/sdk';
const stop = watchWalletEvents(
{ chain: 'stellar', watcher: new WatchWalletChanges(1000) },
(event) => {
switch (event.type) {
case 'accountChanged':
console.log('Account is now', event.address);
break;
case 'networkChanged':
console.log('Network is now', event.network);
break;
case 'disconnect':
console.log(event.error.describe());
break;
}
},
);
// later
stop();Every event has type and chain:
| Event | Fields | Format |
|---|---|---|
accountChanged |
address |
EVM: EIP-55 checksummed, the same form ViemWalletAdapter.getAddress() returns. Solana: base58. Stellar: G.... |
networkChanged |
network |
EVM: CAIP-2 eip155:<chainId>, e.g. eip155:1. Stellar: the network passphrase. Solana: never emitted. |
disconnect |
error (always a WalletNotConnectedError) |
error.context.providerCode and error.cause carry the provider's disconnect error, if it sent one. |
How provider events become normalised events:
| Provider event | Normalised event |
|---|---|
EIP-1193 accountsChanged with accounts |
accountChanged for the first account |
EIP-1193 accountsChanged with [] (locked or disconnected) |
disconnect |
EIP-1193 chainChanged, or connect with a chainId |
networkChanged |
EIP-1193 disconnect |
disconnect |
Solana connect, which adapters also emit when the account changes |
accountChanged |
Solana disconnect |
disconnect |
| Freighter poll: address changed | accountChanged |
| Freighter poll: network passphrase changed | networkChanged |
| Freighter poll: empty address (the site lost access) | disconnect |
The rules that make the stream consistent:
- Repeats are dropped: an account or network is reported only when it changes. EVM addresses are compared after checksumming.
- After a
disconnect, the next account and network are reported again, even if they match the ones from before. - Freighter's watcher polls, so its first poll reports the current state. If the site has no access yet, that is a
disconnect. - Freighter polls that fail are ignored, because they say nothing reliable about the wallet.
- EIP-1193
disconnectcodes follow the WebSocketCloseEventcodes (such as1013), not4900. Everydisconnectevent is treated as a disconnect whatever its code.
ViemWalletAdapter and FreighterWalletAdapter have a getNetwork() method
that returns the wallet's network in the same format as networkChanged
events. assertWalletNetwork() compares it with the network you expect:
import { Networks } from '@stellar/stellar-sdk';
import { assertWalletNetwork } from '@wraith-protocol/sdk';
await assertWalletNetwork(evmAdapter, `eip155:${chain.id}`);
await assertWalletNetwork(stellarAdapter, Networks.TESTNET);It throws WalletWrongNetworkError with expectedNetwork and actualNetwork
in its context.
| Adapter | getNetwork() source |
|---|---|
ViemWalletAdapter |
The client's getChainId(), which every viem WalletClient has. |
FreighterWalletAdapter |
getNetwork() from @stellar/freighter-api 3 or later; pass the API object that includes it. |
SolanaWalletAdapter |
None. @solana/wallet-adapter does not expose the wallet's cluster, so assertWalletNetwork() throws WalletUnavailableError. |
Unlike signMessage() and getAddress(), getNetwork() is new and rejects
with normalised WraithWalletErrors.
The signatures used to derive stealth keys do not depend on the network, so
deriveStealthKeysFromWallet() does not check it. Use assertWalletNetwork()
where your app needs the wallet on a particular network.
Results are shown after normalisation. test/wallet/conformance.test.ts runs
each scenario against all three adapters, with mock providers that reproduce
each library's real error and event shapes.
| Scenario | viem | Solana wallet-adapter | Freighter |
|---|---|---|---|
getAddress() before connecting |
WalletNotConnectedError |
WalletNotConnectedError |
WalletNotConnectedError |
| User declines the connection request | WalletUserRejectedError |
WalletUserRejectedError |
WalletUserRejectedError |
| Wallet disconnects | disconnect event; getAddress() rejects with WalletNotConnectedError |
Same | Same (seen on the next watcher poll) |
signMessage() after a disconnect |
WalletNotConnectedError |
WalletNotConnectedError |
Freighter asks for access again; declining gives WalletUserRejectedError |
| Wallet on another network | networkChanged event; assertWalletNetwork() rejects with WalletWrongNetworkError |
No network events; assertWalletNetwork() rejects with WalletUnavailableError |
networkChanged event; assertWalletNetwork() rejects with WalletWrongNetworkError |
| User rejects the signature | WalletUserRejectedError |
WalletUserRejectedError |
WalletUserRejectedError |
| Retry after rejection, reconnect or network switch | Succeeds; derived keys are identical to a first-time success | Same | Same |
| Wallet unavailable | 4200 gives WalletUnavailableError |
WalletNotReadyError gives WalletUnavailableError |
Node environment error gives WalletUnavailableError |
Wallet object without signMessage |
Constructor TypeError; normalises to WalletUnavailableError |
Same | Same |
The adapters keep no state between calls, so a call can be retried as soon as the user has fixed the cause:
WalletUserRejectedError: don't retry automatically. Let the user try again from your UI.WalletNotConnectedError: reconnect, then retry.WalletWrongNetworkError: switch networks, then retry. On EVM, a switch that fails with4902also maps to this error; add the chain withwallet_addEthereumChainfirst.WalletUnavailableError: retrying won't help until the user installs or enables a suitable wallet.WalletRequestFailedError: the cause may be temporary. Retry once, then checkproviderCodeandcause.
These come from the @stellar/freighter-api 6.0.1 source:
getAddress()returns an empty address, not an error, when the site has no access. When the extension is not installed, the address request resolves empty after a 2-second timeout. Both surface asWalletNotConnectedError; use Freighter'sisConnected()to tell whether the extension is installed.- Only the connection-status and public-key requests have that timeout. Other requests, including
signMessage()andgetNetwork(), wait indefinitely when the extension is missing, so checkisConnected()before calling them. signMessage()asks for access first when the site doesn't have it.