📄 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