📄 src/web3/utils/errors.ts
D-OPEN SOVEREIGN
1import {
2  BaseError as ViemBaseError,
3  ContractFunctionRevertedError,
4  InsufficientFundsError,
5  NonceTooLowError,
6  TimeoutError as ViemTimeoutError,
7  UserRejectedRequestError,
8} from 'viem';
9import type { NormalizedError, ErrorCode } from '../client/types';
10
11const cbs: Array<(err: NormalizedError) => void> = [];
12
13export class Web3Error extends Error implements NormalizedError {
14  public readonly code: ErrorCode;
15  public readonly reason?: string;
16  public readonly data?: any;
17  public readonly originalError?: unknown;
18
19  constructor(
20    code: ErrorCode,
21    message: string,
22    opts?: { reason?: string; data?: any; originalError?: unknown }
23  ) {
24    super(message);
25    this.name = 'Web3Error';
26    this.code = code;
27    this.reason = opts?.reason;
28    this.data = opts?.data;
29    this.originalError = opts?.originalError;
30    Object.setPrototypeOf(this, Web3Error.prototype);
31  }
32}
33
34// Single-tech (EVM-only) since Tech was deleted as a runtime parameter — was
35// `normalizeError(error, ecosystem: Ecosystem)` when this lived in a shared
36// `web3-core` meant to serve multiple chain families. Every caller in this
37// package already only ever passed 'evm'.
38export function normalizeError(error: unknown): Web3Error {
39  // Guard against re-normalizing an already-normalized error (e.g. nested
40  // try/catch or retry wrappers) — otherwise it gets reclassified from its
41  // own synthetic message and every registered callback fires again.
42  if (error instanceof Web3Error) {
43    return error;
44  }
45
46  const err = normalizeEVMError(error);
47  for (const cb of cbs) {
48    try {
49      cb(err);
50    } catch (cbError) {
51      console.error('[web3/errors] registerOnErrorCb listener threw', cbError);
52    }
53  }
54  return err;
55}
56
57export const registerOnErrorCb = (cb: (error: NormalizedError) => void): (() => void) => {
58  cbs.push(cb);
59  return () => {
60    const idx = cbs.indexOf(cb);
61    if (idx !== -1) {
62      cbs.splice(idx, 1);
63    }
64  };
65};
66
67const commonNormalization = (error: any): Web3Error | null => {
68  const message = error?.message || error?.reason || String(error);
69  const messageLower = message.toLowerCase();
70
71  if (messageLower.includes('invalid compressedactions format')) {
72    return new Web3Error('INVALID_COMPRESSED_ACTIONS', 'Invalid compressedActions format', {
73      originalError: error,
74    });
75  }
76
77  if (messageLower.includes('switch chain error')) {
78    return new Web3Error('SWITCH_CHAIN_ERROR', 'Error Switching Chains', {
79      data: error.code,
80      reason: error.message,
81      originalError: error,
82    });
83  }
84
85  if (messageLower.includes('switching accounts while on an unsupported chainid')) {
86    return new Web3Error('SWITCH_TO_UNSUPPORTED_CHAIN', messageLower, { originalError: error });
87  }
88
89  return null;
90};
91
92// viem throws its own typed error hierarchy (BaseError subclasses) for
93// anything that goes through walletClient/publicClient. These carry
94// structured `.shortMessage`/`.reason`/`.data` and are far more reliable
95// than sniffing provider message text, so they're checked before any string
96// matching below. Contract calls wrap the underlying cause in
97// ContractFunctionExecutionError/CallExecutionError, so BaseError.walk()
98// is used to find the specific typed error nested inside.
99const isRecognizedViemError = (error: unknown): boolean =>
100  error instanceof UserRejectedRequestError ||
101  error instanceof InsufficientFundsError ||
102  error instanceof NonceTooLowError ||
103  error instanceof ViemTimeoutError ||
104  error instanceof ContractFunctionRevertedError;
105
106function normalizeTypedViemError(error: unknown): Web3Error | null {
107  if (error instanceof ViemBaseError && !isRecognizedViemError(error)) {
108    const nested = error.walk(isRecognizedViemError);
109    if (nested) {
110      return normalizeTypedViemError(nested);
111    }
112  }
113
114  if (error instanceof UserRejectedRequestError) {
115    return new Web3Error('USER_REJECTED', 'User rejected the transaction', {
116      reason: error.shortMessage,
117      originalError: error,
118    });
119  }
120
121  if (error instanceof InsufficientFundsError) {
122    return new Web3Error('INSUFFICIENT_FUNDS', 'Insufficient funds for transaction', {
123      reason: error.shortMessage,
124      originalError: error,
125    });
126  }
127
128  if (error instanceof NonceTooLowError) {
129    return new Web3Error('NONCE_TOO_LOW', 'Transaction nonce is too low', {
130      reason: error.shortMessage,
131      originalError: error,
132    });
133  }
134
135  if (error instanceof ViemTimeoutError) {
136    return new Web3Error('TIMEOUT', 'Request timed out', {
137      reason: error.shortMessage,
138      originalError: error,
139    });
140  }
141
142  if (error instanceof ContractFunctionRevertedError) {
143    return new Web3Error('REVERT', 'Transaction reverted by contract', {
144      reason: error.reason ?? error.data?.errorName ?? error.shortMessage,
145      data: error.data,
146      originalError: error,
147    });
148  }
149
150  return null;
151}
152
153function normalizeEVMError(error: any): Web3Error {
154  const common = commonNormalization(error);
155  if (common) {
156    return common;
157  }
158
159  const typed = normalizeTypedViemError(error);
160  if (typed) {
161    return typed;
162  }
163
164  const message = error?.message || error?.reason || String(error);
165  const messageLower = message.toLowerCase();
166
167  // No wallet account connected (thrown internally before any wallet/provider call is made)
168  if (messageLower.includes('no account connected to wallet')) {
169    return new Web3Error('NO_WALLET_CONNECTED', 'No account connected to wallet', { originalError: error });
170  }
171
172  // User rejected transaction
173  if (
174    messageLower.includes('user rejected') ||
175    messageLower.includes('user denied') ||
176    messageLower.includes('user cancelled') ||
177    error?.code === 4001 ||
178    error?.code === 'ACTION_REJECTED'
179  ) {
180    return new Web3Error('USER_REJECTED', 'User rejected the transaction', {
181      reason: error?.reason,
182      originalError: error,
183    });
184  }
185
186  // Insufficient funds
187  if (
188    messageLower.includes('insufficient funds') ||
189    messageLower.includes('insufficient balance') ||
190    error?.code === 'INSUFFICIENT_FUNDS'
191  ) {
192    return new Web3Error('INSUFFICIENT_FUNDS', 'Insufficient funds for transaction', {
193      reason: error?.reason,
194      originalError: error,
195    });
196  }
197
198  // Nonce too low
199  if (
200    messageLower.includes('nonce too low') ||
201    messageLower.includes('nonce has already been used') ||
202    error?.code === 'NONCE_EXPIRED'
203  ) {
204    return new Web3Error('NONCE_TOO_LOW', 'Transaction nonce is too low', {
205      reason: error?.reason,
206      originalError: error,
207    });
208  }
209
210  // Contract revert
211  if (
212    messageLower.includes('revert') ||
213    messageLower.includes('execution reverted') ||
214    error?.code === 'CALL_EXCEPTION' ||
215    error?.code === 3
216  ) {
217    return new Web3Error('REVERT', 'Transaction reverted by contract', {
218      reason: error?.reason || extractRevertReason(error),
219      data: error?.data,
220      originalError: error,
221    });
222  }
223
224  // Timeout (checked before the generic network match below, which used to swallow it)
225  if (messageLower.includes('timeout') || error?.code === 'TIMEOUT') {
226    return new Web3Error('TIMEOUT', 'Request timed out', {
227      reason: error?.reason,
228      originalError: error,
229    });
230  }
231
232  // Network errors
233  if (
234    messageLower.includes('network') ||
235    messageLower.includes('connection') ||
236    error?.code === 'NETWORK_ERROR' ||
237    error?.code === 'ETIMEDOUT'
238  ) {
239    return new Web3Error('NETWORK_ERROR', 'Network error occurred', {
240      reason: error?.reason,
241      originalError: error,
242    });
243  }
244
245  // Invalid argument
246  if (messageLower.includes('invalid argument') || error?.code === 'INVALID_ARGUMENT') {
247    return new Web3Error('INVALID_ARGUMENT', 'Invalid argument provided', {
248      reason: error?.reason,
249      originalError: error,
250    });
251  }
252
253  // Unsupported operation
254  if (messageLower.includes('unsupported') || error?.code === 'UNSUPPORTED_OPERATION') {
255    return new Web3Error('UNSUPPORTED_OPERATION', 'Unsupported operation', {
256      reason: error?.reason,
257      originalError: error,
258    });
259  }
260
261  // Default unknown error
262  return new Web3Error('UNKNOWN', message || 'Unknown error occurred', {
263    reason: error?.reason,
264    data: error?.data,
265    originalError: error,
266  });
267}
268
269function extractRevertReason(error: any): string | undefined {
270  if (error?.reason) return error.reason;
271  if (error?.data?.message) return error.data.message;
272
273  const message = error?.message || '';
274  const revertMatch = message.match(/revert\s+(.+?)(?:\s|$|")/i);
275  if (revertMatch) return revertMatch[1];
276
277  return undefined;
278}
279