From 317bee9e08adb2fbdf6435096043c12bdb8eac83 Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:14:54 +0000 Subject: [PATCH] feat: add typed error classes for wallet hooks (#44) Add wagmi-style typed errors: BaseError, WalletNotConnectedError, WalletNotFoundError, UnsupportedMethodError, WalletRequestError. All hooks now throw typed errors with shortMessage, details, walk(), and name-based discrimination. Developers can use instanceof or error.name checks for specific error handling. Co-Authored-By: Claude Opus 4.6 --- .changeset/typed-errors.md | 5 + packages/kit/src/errors.ts | 95 +++++++++++++++++++ packages/kit/src/hooks/use-sign-message.ts | 21 +++- .../src/hooks/use-sign-structured-message.ts | 26 +++-- .../kit/src/hooks/use-sign-transaction.ts | 26 +++-- packages/kit/src/hooks/use-transfer-stx.ts | 21 +++- .../use-write-contract/use-write-contract.ts | 21 +++- packages/kit/src/index.ts | 14 +++ .../unit/hooks/use-sign-message-okx.test.ts | 4 +- .../tests/unit/hooks/use-sign-message.test.ts | 2 +- .../unit/hooks/use-write-contract-okx.test.ts | 2 +- .../unit/hooks/use-write-contract.test.ts | 6 +- 12 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 .changeset/typed-errors.md create mode 100644 packages/kit/src/errors.ts diff --git a/.changeset/typed-errors.md b/.changeset/typed-errors.md new file mode 100644 index 0000000..b483c37 --- /dev/null +++ b/.changeset/typed-errors.md @@ -0,0 +1,5 @@ +--- +"@satoshai/kit": minor +--- + +Add typed error classes (UnsupportedMethodError, WalletNotConnectedError, WalletNotFoundError, WalletRequestError) following wagmi's error pattern. All hooks now throw typed errors that can be checked via `instanceof` or `error.name`. diff --git a/packages/kit/src/errors.ts b/packages/kit/src/errors.ts new file mode 100644 index 0000000..1620a27 --- /dev/null +++ b/packages/kit/src/errors.ts @@ -0,0 +1,95 @@ +export type BaseErrorType = BaseError & { name: 'StacksKitError' } + +export class BaseError extends Error { + override name = 'StacksKitError' + + shortMessage: string + + constructor(shortMessage: string, options?: { cause?: Error; details?: string }) { + const message = [ + shortMessage, + options?.details && `Details: ${options.details}`, + ].filter(Boolean).join('\n\n') + + super(message, options?.cause ? { cause: options.cause } : undefined) + this.shortMessage = shortMessage + } + + walk(fn?: (err: unknown) => boolean): unknown { + return walk(this, fn) + } +} + +function walk(err: unknown, fn?: (err: unknown) => boolean): unknown { + if (fn?.(err)) return err + if (err && typeof err === 'object' && 'cause' in err) { + return walk((err as { cause: unknown }).cause, fn) + } + return err +} + +export type WalletNotConnectedErrorType = WalletNotConnectedError & { + name: 'WalletNotConnectedError' +} + +export class WalletNotConnectedError extends BaseError { + override name = 'WalletNotConnectedError' + + constructor() { + super('Wallet is not connected') + } +} + +export type WalletNotFoundErrorType = WalletNotFoundError & { + name: 'WalletNotFoundError' +} + +export class WalletNotFoundError extends BaseError { + override name = 'WalletNotFoundError' + + wallet: string + + constructor({ wallet }: { wallet: string }) { + super(`${wallet} wallet not found`, { + details: 'The wallet extension may not be installed.', + }) + this.wallet = wallet + } +} + +export type UnsupportedMethodErrorType = UnsupportedMethodError & { + name: 'UnsupportedMethodError' +} + +export class UnsupportedMethodError extends BaseError { + override name = 'UnsupportedMethodError' + + method: string + wallet: string + + constructor({ method, wallet }: { method: string; wallet: string }) { + super(`${method} is not supported by ${wallet} wallet`) + this.method = method + this.wallet = wallet + } +} + +export type WalletRequestErrorType = WalletRequestError & { + name: 'WalletRequestError' +} + +export class WalletRequestError extends BaseError { + override name = 'WalletRequestError' + + method: string + wallet: string + + constructor({ method, wallet, cause }: { method: string; wallet: string; cause: Error }) { + super(`${wallet} wallet request failed`, { + cause, + details: cause.message, + }) + this.method = method + this.wallet = wallet + } +} diff --git a/packages/kit/src/hooks/use-sign-message.ts b/packages/kit/src/hooks/use-sign-message.ts index 83b9c60..22d797b 100644 --- a/packages/kit/src/hooks/use-sign-message.ts +++ b/packages/kit/src/hooks/use-sign-message.ts @@ -3,6 +3,12 @@ import { request } from '@stacks/connect'; import { useCallback, useMemo, useState } from 'react'; +import { + BaseError, + WalletNotConnectedError, + WalletNotFoundError, + WalletRequestError, +} from '../errors'; import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useAddress } from './use-address'; @@ -28,13 +34,13 @@ export interface SignMessageOptions { export const useSignMessage = () => { const { isConnected, provider } = useAddress(); const [data, setData] = useState(undefined); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [status, setStatus] = useState('idle'); const signMessageAsync = useCallback( async (variables: SignMessageVariables): Promise => { if (!isConnected) { - throw new Error('Wallet is not connected'); + throw new WalletNotConnectedError(); } setStatus('pending'); @@ -46,7 +52,7 @@ export const useSignMessage = () => { if (provider === 'okx') { if (!window.okxwallet) { - throw new Error('OKX wallet not found'); + throw new WalletNotFoundError({ wallet: 'OKX' }); } result = await window.okxwallet.stacks.signMessage({ @@ -65,8 +71,13 @@ export const useSignMessage = () => { setStatus('success'); return result; } catch (err) { - const error = - err instanceof Error ? err : new Error(String(err)); + const error = err instanceof BaseError + ? err + : new WalletRequestError({ + method: 'stx_signMessage', + wallet: provider ?? 'unknown', + cause: err instanceof Error ? err : new Error(String(err)), + }); setError(error); setStatus('error'); throw error; diff --git a/packages/kit/src/hooks/use-sign-structured-message.ts b/packages/kit/src/hooks/use-sign-structured-message.ts index f8014b2..c2f2886 100644 --- a/packages/kit/src/hooks/use-sign-structured-message.ts +++ b/packages/kit/src/hooks/use-sign-structured-message.ts @@ -4,6 +4,12 @@ import { request } from '@stacks/connect'; import type { ClarityValue, TupleCV } from '@stacks/transactions'; import { useCallback, useMemo, useState } from 'react'; +import { + BaseError, + WalletNotConnectedError, + UnsupportedMethodError, + WalletRequestError, +} from '../errors'; import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useAddress } from './use-address'; @@ -31,7 +37,7 @@ export const useSignStructuredMessage = () => { const [data, setData] = useState( undefined ); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [status, setStatus] = useState('idle'); const signStructuredMessageAsync = useCallback( @@ -39,13 +45,14 @@ export const useSignStructuredMessage = () => { variables: SignStructuredMessageVariables ): Promise => { if (!isConnected) { - throw new Error('Wallet is not connected'); + throw new WalletNotConnectedError(); } if (provider === 'okx') { - throw new Error( - 'Structured message signing is not supported by OKX wallet' - ); + throw new UnsupportedMethodError({ + method: 'stx_signStructuredMessage', + wallet: 'OKX', + }); } setStatus('pending'); @@ -62,8 +69,13 @@ export const useSignStructuredMessage = () => { setStatus('success'); return result; } catch (err) { - const error = - err instanceof Error ? err : new Error(String(err)); + const error = err instanceof BaseError + ? err + : new WalletRequestError({ + method: 'stx_signStructuredMessage', + wallet: provider ?? 'unknown', + cause: err instanceof Error ? err : new Error(String(err)), + }); setError(error); setStatus('error'); throw error; diff --git a/packages/kit/src/hooks/use-sign-transaction.ts b/packages/kit/src/hooks/use-sign-transaction.ts index ecd4472..748e38e 100644 --- a/packages/kit/src/hooks/use-sign-transaction.ts +++ b/packages/kit/src/hooks/use-sign-transaction.ts @@ -3,6 +3,12 @@ import { request } from '@stacks/connect'; import { useCallback, useMemo, useState } from 'react'; +import { + BaseError, + WalletNotConnectedError, + UnsupportedMethodError, + WalletRequestError, +} from '../errors'; import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useAddress } from './use-address'; @@ -28,7 +34,7 @@ export interface SignTransactionOptions { export const useSignTransaction = () => { const { isConnected, provider } = useAddress(); const [data, setData] = useState(undefined); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [status, setStatus] = useState('idle'); const signTransactionAsync = useCallback( @@ -36,13 +42,14 @@ export const useSignTransaction = () => { variables: SignTransactionVariables ): Promise => { if (!isConnected) { - throw new Error('Wallet is not connected'); + throw new WalletNotConnectedError(); } if (provider === 'okx') { - throw new Error( - 'Transaction signing is not supported by OKX wallet' - ); + throw new UnsupportedMethodError({ + method: 'stx_signTransaction', + wallet: 'OKX', + }); } setStatus('pending'); @@ -61,8 +68,13 @@ export const useSignTransaction = () => { setStatus('success'); return result; } catch (err) { - const error = - err instanceof Error ? err : new Error(String(err)); + const error = err instanceof BaseError + ? err + : new WalletRequestError({ + method: 'stx_signTransaction', + wallet: provider ?? 'unknown', + cause: err instanceof Error ? err : new Error(String(err)), + }); setError(error); setStatus('error'); throw error; diff --git a/packages/kit/src/hooks/use-transfer-stx.ts b/packages/kit/src/hooks/use-transfer-stx.ts index e8bc2d2..148d573 100644 --- a/packages/kit/src/hooks/use-transfer-stx.ts +++ b/packages/kit/src/hooks/use-transfer-stx.ts @@ -3,6 +3,12 @@ import { request } from '@stacks/connect'; import { useCallback, useMemo, useState } from 'react'; +import { + BaseError, + WalletNotConnectedError, + WalletNotFoundError, + WalletRequestError, +} from '../errors'; import type { MutationStatus } from '../provider/stacks-wallet-provider.types'; import { useAddress } from './use-address'; import { getNetworkFromAddress } from '../utils/get-network-from-address'; @@ -24,13 +30,13 @@ export interface TransferSTXOptions { export const useTransferSTX = () => { const { isConnected, address, provider } = useAddress(); const [data, setData] = useState(undefined); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [status, setStatus] = useState('idle'); const transferSTXAsync = useCallback( async (variables: TransferSTXVariables): Promise => { if (!isConnected || !address) { - throw new Error('Wallet is not connected'); + throw new WalletNotConnectedError(); } setStatus('pending'); @@ -40,7 +46,7 @@ export const useTransferSTX = () => { try { if (provider === 'okx') { if (!window.okxwallet) { - throw new Error('OKX wallet not found'); + throw new WalletNotFoundError({ wallet: 'OKX' }); } const response = @@ -81,8 +87,13 @@ export const useTransferSTX = () => { setStatus('success'); return response.txid; } catch (err) { - const error = - err instanceof Error ? err : new Error(String(err)); + const error = err instanceof BaseError + ? err + : new WalletRequestError({ + method: 'stx_transferStx', + wallet: provider ?? 'unknown', + cause: err instanceof Error ? err : new Error(String(err)), + }); setError(error); setStatus('error'); throw error; diff --git a/packages/kit/src/hooks/use-write-contract/use-write-contract.ts b/packages/kit/src/hooks/use-write-contract/use-write-contract.ts index f52dda7..cf6478f 100644 --- a/packages/kit/src/hooks/use-write-contract/use-write-contract.ts +++ b/packages/kit/src/hooks/use-write-contract/use-write-contract.ts @@ -5,6 +5,12 @@ import type { ClarityValue } from '@stacks/transactions'; import { PostConditionMode } from '@stacks/transactions'; import { useCallback, useMemo, useState } from 'react'; +import { + BaseError, + WalletNotConnectedError, + WalletNotFoundError, + WalletRequestError, +} from '../../errors'; import type { MutationStatus } from '../../provider/stacks-wallet-provider.types'; import { useAddress } from '../use-address'; import { getNetworkFromAddress } from '../../utils/get-network-from-address'; @@ -65,13 +71,13 @@ export const useWriteContract = () => { const { isConnected, address, provider } = useAddress(); const [data, setData] = useState(undefined); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [status, setStatus] = useState('idle'); const writeContractAsync = useCallback( async (variables: WriteContractVariablesInternal): Promise => { if (!isConnected || !address) { - throw new Error('Wallet is not connected'); + throw new WalletNotConnectedError(); } setStatus('pending'); @@ -83,7 +89,7 @@ export const useWriteContract = () => { try { if (provider === 'okx') { if (!window.okxwallet) { - throw new Error('OKX wallet not found'); + throw new WalletNotFoundError({ wallet: 'OKX' }); } const response = @@ -127,8 +133,13 @@ export const useWriteContract = () => { setStatus('success'); return response.txid; } catch (err) { - const error = - err instanceof Error ? err : new Error(String(err)); + const error = err instanceof BaseError + ? err + : new WalletRequestError({ + method: 'stx_callContract', + wallet: provider ?? 'unknown', + cause: err instanceof Error ? err : new Error(String(err)), + }); setError(error); setStatus('error'); throw error; diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index ac57748..6a0f008 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -1,3 +1,17 @@ +// Errors +export { + BaseError, + type BaseErrorType, + WalletNotConnectedError, + type WalletNotConnectedErrorType, + WalletNotFoundError, + type WalletNotFoundErrorType, + UnsupportedMethodError, + type UnsupportedMethodErrorType, + WalletRequestError, + type WalletRequestErrorType, +} from './errors'; + // Provider export { StacksWalletProvider } from './provider/stacks-wallet-provider'; diff --git a/packages/kit/tests/unit/hooks/use-sign-message-okx.test.ts b/packages/kit/tests/unit/hooks/use-sign-message-okx.test.ts index 66216f7..766109d 100644 --- a/packages/kit/tests/unit/hooks/use-sign-message-okx.test.ts +++ b/packages/kit/tests/unit/hooks/use-sign-message-okx.test.ts @@ -55,7 +55,7 @@ describe('useSignMessage (OKX)', () => { }); expect(result.current.isError).toBe(true); - expect(result.current.error?.message).toBe('OKX wallet not found'); + expect(result.current.error?.shortMessage).toBe('OKX wallet not found'); }); it('transitions through pending to success', async () => { @@ -93,6 +93,6 @@ describe('useSignMessage (OKX)', () => { }); expect(result.current.isError).toBe(true); - expect(result.current.error?.message).toBe('User cancelled'); + expect(result.current.error?.shortMessage).toBe('okx wallet request failed'); }); }); diff --git a/packages/kit/tests/unit/hooks/use-sign-message.test.ts b/packages/kit/tests/unit/hooks/use-sign-message.test.ts index 0c14964..23889d7 100644 --- a/packages/kit/tests/unit/hooks/use-sign-message.test.ts +++ b/packages/kit/tests/unit/hooks/use-sign-message.test.ts @@ -65,7 +65,7 @@ describe('useSignMessage', () => { expect(result.current.status).toBe('error'); expect(result.current.isError).toBe(true); - expect(result.current.error?.message).toBe('User rejected'); + expect(result.current.error?.shortMessage).toBe('leather wallet request failed'); expect(result.current.data).toBeUndefined(); }); diff --git a/packages/kit/tests/unit/hooks/use-write-contract-okx.test.ts b/packages/kit/tests/unit/hooks/use-write-contract-okx.test.ts index 93ff4b4..6de136a 100644 --- a/packages/kit/tests/unit/hooks/use-write-contract-okx.test.ts +++ b/packages/kit/tests/unit/hooks/use-write-contract-okx.test.ts @@ -79,7 +79,7 @@ describe('useWriteContract (OKX)', () => { }); expect(result.current.isError).toBe(true); - expect(result.current.error?.message).toBe('OKX wallet not found'); + expect(result.current.error?.shortMessage).toBe('OKX wallet not found'); }); it('passes correct params to OKX signTransaction', async () => { diff --git a/packages/kit/tests/unit/hooks/use-write-contract.test.ts b/packages/kit/tests/unit/hooks/use-write-contract.test.ts index 170602a..4094dd2 100644 --- a/packages/kit/tests/unit/hooks/use-write-contract.test.ts +++ b/packages/kit/tests/unit/hooks/use-write-contract.test.ts @@ -187,9 +187,7 @@ describe('useWriteContract', () => { }); expect(result.current.isError).toBe(true); - expect(result.current.error?.message).toBe( - 'No transaction ID returned' - ); + expect(result.current.error?.shortMessage).toBe('leather wallet request failed'); }); it('transitions to error on failure', async () => { @@ -205,7 +203,7 @@ describe('useWriteContract', () => { expect(result.current.status).toBe('error'); expect(result.current.isError).toBe(true); - expect(result.current.error?.message).toBe('User rejected'); + expect(result.current.error?.shortMessage).toBe('leather wallet request failed'); expect(result.current.data).toBeUndefined(); });