diff --git a/src/services/__tests__/walletService.test.ts b/src/services/__tests__/walletService.test.ts index 069ea38..e7272ca 100644 --- a/src/services/__tests__/walletService.test.ts +++ b/src/services/__tests__/walletService.test.ts @@ -3,6 +3,9 @@ import { WalletConnection, TokenBalance, GasEstimate, + WalletError, + WalletErrorCode, + errorTracker, } from '../walletService'; import { ethers } from 'ethers'; import { getContractAddress, ERC20__factory } from '../../contracts'; @@ -250,42 +253,58 @@ describe('WalletServiceManager', () => { }); describe('getWalletSigner (private)', () => { - it('throws when no connection', () => { + it('throws WalletError with NOT_CONNECTED code when no connection', () => { const mgr = freshManager(); - // Access private via casting - expect(() => (mgr as any).getWalletSigner()).toThrow('Wallet is not connected'); + try { + (mgr as any).getWalletSigner(); + fail('expected to throw'); + } catch (e) { + expect(e).toBeInstanceOf(WalletError); + expect((e as WalletError).code).toBe(WalletErrorCode.NOT_CONNECTED); + expect((e as WalletError).userMessage).toBe('Wallet is not connected.'); + expect((e as WalletError).recovery).toBeDefined(); + } }); - it('throws when connection has no eip1193Provider', () => { + it('throws WalletError when connection has no eip1193Provider', () => { const mgr = freshManager(); mgr.setConnection(createMockConnection({ eip1193Provider: undefined })); - expect(() => (mgr as any).getWalletSigner()).toThrow('does not expose a signing provider'); + try { + (mgr as any).getWalletSigner(); + fail('expected to throw'); + } catch (e) { + expect(e).toBeInstanceOf(WalletError); + expect((e as WalletError).code).toBe(WalletErrorCode.NOT_CONNECTED); + } }); }); describe('createSuperfluidStream – user rejection', () => { - it('throws friendly error when user rejects transaction', async () => { + it('throws WalletError USER_REJECTED when user rejects transaction', async () => { const mgr = freshManager(); const mockSigner = { provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) }, getAddress: jest.fn().mockResolvedValue('0xSender'), }; jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); - - // Mock buildSuperfluidCreateFlowContext to throw rejection-like error jest.spyOn(mgr as any, 'buildSuperfluidCreateFlowContext').mockRejectedValue({ code: 4001, message: 'User rejected', }); - await expect(mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1)).rejects.toThrow( - 'Transaction was rejected in your wallet.' - ); + try { + await mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1); + fail('expected to throw'); + } catch (e) { + expect(e).toBeInstanceOf(WalletError); + expect((e as WalletError).code).toBe(WalletErrorCode.USER_REJECTED); + expect((e as WalletError).recovery).toBeDefined(); + } }); }); describe('createSuperfluidStream – user denied (string code)', () => { - it('throws friendly error for ACTION_REJECTED code', async () => { + it('throws WalletError USER_REJECTED for ACTION_REJECTED code', async () => { const mgr = freshManager(); const mockSigner = { provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) }, @@ -296,9 +315,13 @@ describe('WalletServiceManager', () => { code: 'ACTION_REJECTED', }); - await expect(mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1)).rejects.toThrow( - 'Transaction was rejected in your wallet.' - ); + try { + await mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1); + fail('expected to throw'); + } catch (e) { + expect(e).toBeInstanceOf(WalletError); + expect((e as WalletError).code).toBe(WalletErrorCode.USER_REJECTED); + } }); }); @@ -317,7 +340,7 @@ describe('WalletServiceManager', () => { }); describe('createSablierStream – user denied via message', () => { - it('throws friendly error for user denied message', async () => { + it('throws WalletError USER_REJECTED for user denied message', async () => { const mgr = freshManager(); const mockSigner = { provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) }, @@ -325,21 +348,78 @@ describe('WalletServiceManager', () => { }; jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner); - // Simulate a generic error with "user denied" in message jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => { throw new Error('user denied transaction'); }); - await expect( - mgr.createSablierStream( - '0xToken', - '10', - Date.now(), - Date.now() + 86400000, - '0xRecipient', - 1 - ) - ).rejects.toThrow('Transaction was rejected in your wallet.'); + try { + await mgr.createSablierStream('0xToken', '10', Date.now(), Date.now() + 86400000, '0xRecipient', 1); + fail('expected to throw'); + } catch (e) { + expect(e).toBeInstanceOf(WalletError); + expect((e as WalletError).code).toBe(WalletErrorCode.USER_REJECTED); + } + }); + }); + + describe('WalletError structure', () => { + it('has code, userMessage, and recovery fields', () => { + const err = new WalletError( + WalletErrorCode.STREAM_CREATION_FAILED, + 'Stream creation failed.', + 'Check your token balance and try again.' + ); + expect(err.code).toBe(WalletErrorCode.STREAM_CREATION_FAILED); + expect(err.userMessage).toBe('Stream creation failed.'); + expect(err.recovery).toBe('Check your token balance and try again.'); + expect(err.name).toBe('WalletError'); + }); + + it('preserves cause stack when cause is an Error', () => { + const cause = new Error('rpc timeout'); + const err = new WalletError(WalletErrorCode.UNKNOWN, 'Something went wrong.', undefined, cause); + expect(err.stack).toContain('Caused by:'); + }); + }); + + describe('errorTracker', () => { + beforeEach(() => errorTracker.reset()); + + it('records error counts by code', () => { + errorTracker.record(WalletErrorCode.USER_REJECTED); + errorTracker.record(WalletErrorCode.USER_REJECTED); + errorTracker.record(WalletErrorCode.NOT_CONNECTED); + const stats = errorTracker.getStats(); + expect(stats[WalletErrorCode.USER_REJECTED].count).toBe(2); + expect(stats[WalletErrorCode.NOT_CONNECTED].count).toBe(1); + }); + + it('reset clears all counts', () => { + errorTracker.record(WalletErrorCode.APPROVAL_FAILED); + errorTracker.reset(); + expect(Object.keys(errorTracker.getStats()).length).toBe(0); + }); + }); + + describe('getTokenBalances – structured error', () => { + it('throws WalletError BALANCE_FETCH_FAILED when provider fails', async () => { + const mgr = freshManager(); + const mockProvider = { + getBalance: jest.fn().mockRejectedValue(new Error('RPC down')), + getGasPrice: jest.fn(), + }; + jest + .spyOn(ethers.providers, 'JsonRpcProvider') + .mockImplementation(() => mockProvider as unknown as ethers.providers.JsonRpcProvider); + + try { + await mgr.getTokenBalances('0xAddr', 1); + fail('expected to throw'); + } catch (e) { + expect(e).toBeInstanceOf(WalletError); + expect((e as WalletError).code).toBe(WalletErrorCode.BALANCE_FETCH_FAILED); + expect((e as WalletError).recovery).toBeDefined(); + } }); }); }); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index dd978ab..8767bbe 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -10,6 +10,74 @@ import { ADDRESS_CONSTANTS, } from '../utils/constants/values'; +// ── Structured error handling ────────────────────────────────────── + +export enum WalletErrorCode { + NOT_CONNECTED = 'WALLET_NOT_CONNECTED', + USER_REJECTED = 'USER_REJECTED', + NETWORK_MISMATCH = 'NETWORK_MISMATCH', + BALANCE_FETCH_FAILED = 'BALANCE_FETCH_FAILED', + GAS_ESTIMATION_FAILED = 'GAS_ESTIMATION_FAILED', + STREAM_CREATION_FAILED = 'STREAM_CREATION_FAILED', + APPROVAL_FAILED = 'APPROVAL_FAILED', + INVALID_PARAMS = 'INVALID_PARAMS', + UNKNOWN = 'UNKNOWN', +} + +export class WalletError extends Error { + readonly code: WalletErrorCode; + readonly userMessage: string; + readonly recovery?: string; + + constructor( + code: WalletErrorCode, + userMessage: string, + recovery?: string, + cause?: unknown + ) { + super(userMessage); + this.name = 'WalletError'; + this.code = code; + this.userMessage = userMessage; + this.recovery = recovery; + // Preserve original stack if available + if (cause instanceof Error && cause.stack) { + this.stack = `${this.stack}\nCaused by: ${cause.stack}`; + } + } +} + +// ── Error rate tracker ───────────────────────────────────────────── + +interface ErrorRecord { + count: number; + lastSeen: number; +} + +class ErrorRateTracker { + private readonly counts = new Map(); + + record(code: WalletErrorCode): void { + const existing = this.counts.get(code); + if (existing) { + existing.count += 1; + existing.lastSeen = Date.now(); + } else { + this.counts.set(code, { count: 1, lastSeen: Date.now() }); + } + } + + getStats(): Record { + return Object.fromEntries(this.counts.entries()); + } + + reset(): void { + this.counts.clear(); + } +} + +export const errorTracker = new ErrorRateTracker(); + export interface WalletConnection { address: string; chainId: number; @@ -77,14 +145,16 @@ function superTokenResolverSymbol(chainId: number, tokenSymbol: string): string return `${s}x`; } -function formatSuperfluidError(error: unknown): string { - if (error instanceof SFError) { - return error.message; - } - if (error instanceof Error) { - return error.message; - } - return 'Superfluid stream creation failed'; +function toWalletError( + error: unknown, + code: WalletErrorCode, + userMessage: string, + recovery?: string +): WalletError { + errorTracker.record(code); + // Log full detail for debugging without leaking to the user + console.error(`[WalletError] ${code}:`, error); + return new WalletError(code, userMessage, recovery, error); } // This is a hook-based service that needs to be used within React components @@ -191,8 +261,12 @@ export class WalletServiceManager { return balances; } catch (error) { - console.error('Failed to get token balances:', error); - throw error; + throw toWalletError( + error, + WalletErrorCode.BALANCE_FETCH_FAILED, + 'Unable to fetch token balances.', + 'Check your network connection and try again.' + ); } } @@ -203,9 +277,20 @@ export class WalletServiceManager { chainId: number, userGasLimitOverride?: string ): Promise { - const provider = this.getProvider(chainId); + let provider: ethers.providers.JsonRpcProvider; + let gasPrice: ethers.BigNumber; - const gasPrice = await this.resolveGasPrice(provider); + try { + provider = this.getProvider(chainId); + gasPrice = await this.resolveGasPrice(provider); + } catch (error) { + throw toWalletError( + error, + WalletErrorCode.GAS_ESTIMATION_FAILED, + 'Could not retrieve gas price.', + 'Check your network connection and try again.' + ); + } let gasLimit: ethers.BigNumber; @@ -241,7 +326,13 @@ export class WalletServiceManager { private getWalletSigner(): ethers.Signer { const conn = this.connection; if (!conn?.eip1193Provider) { - throw new Error('Wallet is not connected or does not expose a signing provider.'); + const err = new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Wallet is not connected.', + 'Connect your wallet and try again.' + ); + errorTracker.record(WalletErrorCode.NOT_CONNECTED); + throw err; } const web3Provider = new ethers.providers.Web3Provider(conn.eip1193Provider); return web3Provider.getSigner(); @@ -370,10 +461,19 @@ export class WalletServiceManager { }; } catch (error) { if (isUserRejectedError(error)) { - throw new Error('Transaction was rejected in your wallet.'); + errorTracker.record(WalletErrorCode.USER_REJECTED); + throw new WalletError( + WalletErrorCode.USER_REJECTED, + 'Transaction was rejected in your wallet.', + 'Open your wallet and approve the transaction to continue.' + ); } - console.error('Failed to create Superfluid stream:', error); - throw new Error(formatSuperfluidError(error)); + throw toWalletError( + error, + WalletErrorCode.STREAM_CREATION_FAILED, + 'Stream creation failed.', + 'Check your token balance and try again.' + ); } } @@ -453,10 +553,19 @@ export class WalletServiceManager { return receipt.transactionHash; } catch (error) { if (isUserRejectedError(error)) { - throw new Error('Transaction was rejected in your wallet.'); + errorTracker.record(WalletErrorCode.USER_REJECTED); + throw new WalletError( + WalletErrorCode.USER_REJECTED, + 'Transaction was rejected in your wallet.', + 'Open your wallet and approve the transaction to continue.' + ); } - console.error('Failed to create Sablier stream:', error); - throw error; + throw toWalletError( + error, + WalletErrorCode.STREAM_CREATION_FAILED, + 'Stream creation failed.', + 'Check your token balance and allowance, then try again.' + ); } } @@ -490,7 +599,13 @@ export class WalletServiceManager { const erc20Abi = ['function approve(address spender, uint256 amount) returns (bool)']; const conn = this.connection; if (!conn?.eip1193Provider) { - throw new Error('Wallet is not connected for gas estimation.'); + const err = new WalletError( + WalletErrorCode.NOT_CONNECTED, + 'Wallet is not connected.', + 'Connect your wallet and try again.' + ); + errorTracker.record(WalletErrorCode.NOT_CONNECTED); + throw err; } const web3Provider = new ethers.providers.Web3Provider(conn.eip1193Provider); const signer = web3Provider.getSigner(); @@ -525,12 +640,29 @@ export class WalletServiceManager { const signer = this.getWalletSigner(); const erc20Abi = ['function approve(address spender, uint256 amount) returns (bool)']; const erc20 = new ethers.Contract(token, erc20Abi, signer); - const tx = await erc20.approve(spender, amount); - const receipt = await tx.wait(); - if (!receipt?.transactionHash) { - throw new Error('Approval transaction mined without a hash'); + try { + const tx = await erc20.approve(spender, amount); + const receipt = await tx.wait(); + if (!receipt?.transactionHash) { + throw new Error('Approval transaction mined without a hash'); + } + return receipt.transactionHash; + } catch (error) { + if (isUserRejectedError(error)) { + errorTracker.record(WalletErrorCode.USER_REJECTED); + throw new WalletError( + WalletErrorCode.USER_REJECTED, + 'Approval was rejected in your wallet.', + 'Open your wallet and approve the request to continue.' + ); + } + throw toWalletError( + error, + WalletErrorCode.APPROVAL_FAILED, + 'Token approval failed.', + 'Check your wallet connection and try again.' + ); } - return receipt.transactionHash; } private getProvider(chainId: number): ethers.providers.JsonRpcProvider {