Skip to content

Commit 2b2bf27

Browse files
authored
fix(wallet): structured error handling with WalletError class and error rate tracking (#326)
- Add WalletError class with code, userMessage, and recovery fields - Add WalletErrorCode enum covering all failure scenarios - Add ErrorRateTracker to record error frequency by code - Replace raw throw/console.error patterns with toWalletError helper - Wrap getTokenBalances, estimateGas, createSuperfluidStream, createSablierStream, approveErc20, estimateApproveGas, getWalletSigner - User-facing messages are short and safe (no sensitive info exposed) - Recovery hints provided where actionable - Update tests to assert on WalletError shape, code, and recovery - Add coverage for errorTracker and WalletError cause stack Closes #41
1 parent b411578 commit 2b2bf27

2 files changed

Lines changed: 264 additions & 52 deletions

File tree

src/services/__tests__/walletService.test.ts

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import {
33
WalletConnection,
44
TokenBalance,
55
GasEstimate,
6+
WalletError,
7+
WalletErrorCode,
8+
errorTracker,
69
} from '../walletService';
710
import { ethers } from 'ethers';
811
import { getContractAddress, ERC20__factory } from '../../contracts';
@@ -250,42 +253,58 @@ describe('WalletServiceManager', () => {
250253
});
251254

252255
describe('getWalletSigner (private)', () => {
253-
it('throws when no connection', () => {
256+
it('throws WalletError with NOT_CONNECTED code when no connection', () => {
254257
const mgr = freshManager();
255-
// Access private via casting
256-
expect(() => (mgr as any).getWalletSigner()).toThrow('Wallet is not connected');
258+
try {
259+
(mgr as any).getWalletSigner();
260+
fail('expected to throw');
261+
} catch (e) {
262+
expect(e).toBeInstanceOf(WalletError);
263+
expect((e as WalletError).code).toBe(WalletErrorCode.NOT_CONNECTED);
264+
expect((e as WalletError).userMessage).toBe('Wallet is not connected.');
265+
expect((e as WalletError).recovery).toBeDefined();
266+
}
257267
});
258268

259-
it('throws when connection has no eip1193Provider', () => {
269+
it('throws WalletError when connection has no eip1193Provider', () => {
260270
const mgr = freshManager();
261271
mgr.setConnection(createMockConnection({ eip1193Provider: undefined }));
262-
expect(() => (mgr as any).getWalletSigner()).toThrow('does not expose a signing provider');
272+
try {
273+
(mgr as any).getWalletSigner();
274+
fail('expected to throw');
275+
} catch (e) {
276+
expect(e).toBeInstanceOf(WalletError);
277+
expect((e as WalletError).code).toBe(WalletErrorCode.NOT_CONNECTED);
278+
}
263279
});
264280
});
265281

266282
describe('createSuperfluidStream – user rejection', () => {
267-
it('throws friendly error when user rejects transaction', async () => {
283+
it('throws WalletError USER_REJECTED when user rejects transaction', async () => {
268284
const mgr = freshManager();
269285
const mockSigner = {
270286
provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) },
271287
getAddress: jest.fn().mockResolvedValue('0xSender'),
272288
};
273289
jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner);
274-
275-
// Mock buildSuperfluidCreateFlowContext to throw rejection-like error
276290
jest.spyOn(mgr as any, 'buildSuperfluidCreateFlowContext').mockRejectedValue({
277291
code: 4001,
278292
message: 'User rejected',
279293
});
280294

281-
await expect(mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1)).rejects.toThrow(
282-
'Transaction was rejected in your wallet.'
283-
);
295+
try {
296+
await mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1);
297+
fail('expected to throw');
298+
} catch (e) {
299+
expect(e).toBeInstanceOf(WalletError);
300+
expect((e as WalletError).code).toBe(WalletErrorCode.USER_REJECTED);
301+
expect((e as WalletError).recovery).toBeDefined();
302+
}
284303
});
285304
});
286305

287306
describe('createSuperfluidStream – user denied (string code)', () => {
288-
it('throws friendly error for ACTION_REJECTED code', async () => {
307+
it('throws WalletError USER_REJECTED for ACTION_REJECTED code', async () => {
289308
const mgr = freshManager();
290309
const mockSigner = {
291310
provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) },
@@ -296,9 +315,13 @@ describe('WalletServiceManager', () => {
296315
code: 'ACTION_REJECTED',
297316
});
298317

299-
await expect(mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1)).rejects.toThrow(
300-
'Transaction was rejected in your wallet.'
301-
);
318+
try {
319+
await mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1);
320+
fail('expected to throw');
321+
} catch (e) {
322+
expect(e).toBeInstanceOf(WalletError);
323+
expect((e as WalletError).code).toBe(WalletErrorCode.USER_REJECTED);
324+
}
302325
});
303326
});
304327

@@ -317,29 +340,86 @@ describe('WalletServiceManager', () => {
317340
});
318341

319342
describe('createSablierStream – user denied via message', () => {
320-
it('throws friendly error for user denied message', async () => {
343+
it('throws WalletError USER_REJECTED for user denied message', async () => {
321344
const mgr = freshManager();
322345
const mockSigner = {
323346
provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) },
324347
getAddress: jest.fn().mockResolvedValue('0xSender'),
325348
};
326349
jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner);
327350

328-
// Simulate a generic error with "user denied" in message
329351
jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => {
330352
throw new Error('user denied transaction');
331353
});
332354

333-
await expect(
334-
mgr.createSablierStream(
335-
'0xToken',
336-
'10',
337-
Date.now(),
338-
Date.now() + 86400000,
339-
'0xRecipient',
340-
1
341-
)
342-
).rejects.toThrow('Transaction was rejected in your wallet.');
355+
try {
356+
await mgr.createSablierStream('0xToken', '10', Date.now(), Date.now() + 86400000, '0xRecipient', 1);
357+
fail('expected to throw');
358+
} catch (e) {
359+
expect(e).toBeInstanceOf(WalletError);
360+
expect((e as WalletError).code).toBe(WalletErrorCode.USER_REJECTED);
361+
}
362+
});
363+
});
364+
365+
describe('WalletError structure', () => {
366+
it('has code, userMessage, and recovery fields', () => {
367+
const err = new WalletError(
368+
WalletErrorCode.STREAM_CREATION_FAILED,
369+
'Stream creation failed.',
370+
'Check your token balance and try again.'
371+
);
372+
expect(err.code).toBe(WalletErrorCode.STREAM_CREATION_FAILED);
373+
expect(err.userMessage).toBe('Stream creation failed.');
374+
expect(err.recovery).toBe('Check your token balance and try again.');
375+
expect(err.name).toBe('WalletError');
376+
});
377+
378+
it('preserves cause stack when cause is an Error', () => {
379+
const cause = new Error('rpc timeout');
380+
const err = new WalletError(WalletErrorCode.UNKNOWN, 'Something went wrong.', undefined, cause);
381+
expect(err.stack).toContain('Caused by:');
382+
});
383+
});
384+
385+
describe('errorTracker', () => {
386+
beforeEach(() => errorTracker.reset());
387+
388+
it('records error counts by code', () => {
389+
errorTracker.record(WalletErrorCode.USER_REJECTED);
390+
errorTracker.record(WalletErrorCode.USER_REJECTED);
391+
errorTracker.record(WalletErrorCode.NOT_CONNECTED);
392+
const stats = errorTracker.getStats();
393+
expect(stats[WalletErrorCode.USER_REJECTED].count).toBe(2);
394+
expect(stats[WalletErrorCode.NOT_CONNECTED].count).toBe(1);
395+
});
396+
397+
it('reset clears all counts', () => {
398+
errorTracker.record(WalletErrorCode.APPROVAL_FAILED);
399+
errorTracker.reset();
400+
expect(Object.keys(errorTracker.getStats()).length).toBe(0);
401+
});
402+
});
403+
404+
describe('getTokenBalances – structured error', () => {
405+
it('throws WalletError BALANCE_FETCH_FAILED when provider fails', async () => {
406+
const mgr = freshManager();
407+
const mockProvider = {
408+
getBalance: jest.fn().mockRejectedValue(new Error('RPC down')),
409+
getGasPrice: jest.fn(),
410+
};
411+
jest
412+
.spyOn(ethers.providers, 'JsonRpcProvider')
413+
.mockImplementation(() => mockProvider as unknown as ethers.providers.JsonRpcProvider);
414+
415+
try {
416+
await mgr.getTokenBalances('0xAddr', 1);
417+
fail('expected to throw');
418+
} catch (e) {
419+
expect(e).toBeInstanceOf(WalletError);
420+
expect((e as WalletError).code).toBe(WalletErrorCode.BALANCE_FETCH_FAILED);
421+
expect((e as WalletError).recovery).toBeDefined();
422+
}
343423
});
344424
});
345425
});

0 commit comments

Comments
 (0)