📄 src/web3/utils/errors.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi } from 'vitest';
2import { CallExecutionError, InsufficientFundsError, UserRejectedRequestError } from 'viem';
3import { normalizeError, registerOnErrorCb, Web3Error } from './errors';
4
5describe('normalizeError', () => {
6  it('maps user rejection messages to USER_REJECTED', () => {
7    expect(normalizeError(new Error('User rejected the request')).code).toBe('USER_REJECTED');
8    expect(normalizeError(new Error('User denied transaction signature')).code).toBe('USER_REJECTED');
9    expect(normalizeError({ code: 4001, message: 'rejected' }).code).toBe('USER_REJECTED');
10    expect(normalizeError({ code: 'ACTION_REJECTED', message: 'rejected' }).code).toBe('USER_REJECTED');
11  });
12
13  it('maps insufficient funds messages to INSUFFICIENT_FUNDS', () => {
14    expect(normalizeError(new Error('insufficient funds for gas')).code).toBe('INSUFFICIENT_FUNDS');
15    expect(normalizeError({ code: 'INSUFFICIENT_FUNDS', message: 'nope' }).code).toBe('INSUFFICIENT_FUNDS');
16  });
17
18  it('maps nonce errors to NONCE_TOO_LOW', () => {
19    expect(normalizeError(new Error('Nonce too low')).code).toBe('NONCE_TOO_LOW');
20    expect(normalizeError({ code: 'NONCE_EXPIRED', message: 'x' }).code).toBe('NONCE_TOO_LOW');
21  });
22
23  it('maps revert messages to REVERT and extracts a reason', () => {
24    const result = normalizeError(new Error('execution reverted: revert Custom reason here'));
25    expect(result.code).toBe('REVERT');
26    expect(result.reason).toBe('Custom');
27  });
28
29  it('prefers an explicit reason field over regex extraction on revert', () => {
30    const result = normalizeError({ message: 'reverted', reason: 'explicit reason' });
31    expect(result.code).toBe('REVERT');
32    expect(result.reason).toBe('explicit reason');
33  });
34
35  it('maps network-ish messages to NETWORK_ERROR', () => {
36    expect(normalizeError(new Error('network connection lost')).code).toBe('NETWORK_ERROR');
37    expect(normalizeError({ code: 'ETIMEDOUT', message: 'x' }).code).toBe('NETWORK_ERROR');
38  });
39
40  it('maps timeout messages to TIMEOUT rather than the generic NETWORK_ERROR', () => {
41    expect(normalizeError(new Error('request timeout exceeded')).code).toBe('TIMEOUT');
42    expect(normalizeError({ code: 'TIMEOUT', message: 'x' }).code).toBe('TIMEOUT');
43  });
44
45  it('maps the internal "no account connected" guard message to NO_WALLET_CONNECTED', () => {
46    const result = normalizeError(new Error('No account connected to wallet'));
47    expect(result.code).toBe('NO_WALLET_CONNECTED');
48  });
49
50  it('maps invalid argument messages to INVALID_ARGUMENT', () => {
51    expect(normalizeError(new Error('invalid argument: foo')).code).toBe('INVALID_ARGUMENT');
52  });
53
54  it('maps unsupported operation messages to UNSUPPORTED_OPERATION', () => {
55    expect(normalizeError(new Error('unsupported operation')).code).toBe('UNSUPPORTED_OPERATION');
56  });
57
58  it('falls back to UNKNOWN for unrecognized errors', () => {
59    const result = normalizeError(new Error('something totally unexpected'));
60    expect(result.code).toBe('UNKNOWN');
61    expect(result.message).toBe('something totally unexpected');
62  });
63
64  it('handles non-Error inputs by stringifying them', () => {
65    const result = normalizeError('just a string');
66    expect(result.code).toBe('UNKNOWN');
67  });
68
69  describe('common (cross-cutting) normalizations', () => {
70    it('detects invalid compressedActions format before other rules', () => {
71      const result = normalizeError(new Error('Invalid compressedActions format'));
72      expect(result.code).toBe('INVALID_COMPRESSED_ACTIONS');
73    });
74
75    it('detects chain-switch errors', () => {
76      const result = normalizeError({ message: 'Switch chain error', code: -1 });
77      expect(result.code).toBe('SWITCH_CHAIN_ERROR');
78      expect(result.data).toBe(-1);
79    });
80
81    it('detects switching accounts while on an unsupported chain', () => {
82      const result = normalizeError(new Error('Switching accounts while on an unsupported chainId'));
83      expect(result.code).toBe('SWITCH_TO_UNSUPPORTED_CHAIN');
84    });
85  });
86
87  describe('Web3Error shape', () => {
88    it('returns a real Error instance, not a plain object', () => {
89      const result = normalizeError(new Error('user rejected the request'));
90      expect(result).toBeInstanceOf(Error);
91      expect(result).toBeInstanceOf(Web3Error);
92      expect(result.stack).toBeDefined();
93    });
94
95    it('is idempotent — re-normalizing an already-normalized error returns it unchanged', () => {
96      const first = normalizeError(new Error('insufficient funds for gas'));
97      const second = normalizeError(first);
98      expect(second).toBe(first);
99    });
100
101    it('does not re-fire callbacks when an already-normalized error is passed back in', () => {
102      const cb = vi.fn();
103      const unregister = registerOnErrorCb(cb);
104
105      const first = normalizeError(new Error('nonce too low'));
106      normalizeError(first);
107
108      expect(cb).toHaveBeenCalledTimes(1);
109      unregister();
110    });
111  });
112
113  describe('typed viem errors', () => {
114    it('classifies a viem UserRejectedRequestError via instanceof, independent of message text', () => {
115      const err = new UserRejectedRequestError(new Error('some opaque provider text'));
116      const result = normalizeError(err);
117      expect(result.code).toBe('USER_REJECTED');
118      expect(result.originalError).toBe(err);
119    });
120
121    it('classifies a viem InsufficientFundsError via instanceof', () => {
122      const result = normalizeError(new InsufficientFundsError());
123      expect(result.code).toBe('INSUFFICIENT_FUNDS');
124    });
125
126    it('walks a wrapped CallExecutionError to find the nested typed error', () => {
127      const inner = new UserRejectedRequestError(new Error('User rejected the request.'));
128      const wrapped = new CallExecutionError(inner, { chain: undefined as any });
129      const result = normalizeError(wrapped);
130      expect(result.code).toBe('USER_REJECTED');
131    });
132  });
133
134  describe('registerOnErrorCb', () => {
135    it('invokes registered callbacks with the normalized error', () => {
136      const cb = vi.fn();
137      const unregister = registerOnErrorCb(cb);
138
139      const err = new Error('user rejected the request');
140      normalizeError(err);
141
142      expect(cb).toHaveBeenCalledTimes(1);
143      expect(cb).toHaveBeenCalledWith(expect.objectContaining({ code: 'USER_REJECTED' }));
144
145      unregister();
146    });
147
148    it('still invokes remaining callbacks, and still returns the normalized error, when one callback throws', () => {
149      const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
150      const throwingCb = vi.fn(() => { throw new Error('listener bug'); });
151      const healthyCb = vi.fn();
152      const unregisterThrowing = registerOnErrorCb(throwingCb);
153      const unregisterHealthy = registerOnErrorCb(healthyCb);
154
155      const result = normalizeError(new Error('user rejected the request'));
156
157      expect(result.code).toBe('USER_REJECTED');
158      expect(throwingCb).toHaveBeenCalledTimes(1);
159      expect(healthyCb).toHaveBeenCalledTimes(1);
160
161      unregisterThrowing();
162      unregisterHealthy();
163      consoleSpy.mockRestore();
164    });
165
166    it('stops invoking a callback after it is unregistered', () => {
167      const cb = vi.fn();
168      const unregister = registerOnErrorCb(cb);
169      unregister();
170
171      normalizeError(new Error('user rejected the request'));
172
173      expect(cb).not.toHaveBeenCalled();
174    });
175  });
176});
177