📄 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