@exodus/ethereum-api 8.64.6 → 8.64.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [8.64.7](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.64.5...@exodus/ethereum-api@8.64.7) (2026-02-24)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: harden EthlikeError and improve error handling in tx-send (#7308)
13
+
14
+
15
+
6
16
  ## [8.64.6](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.64.5...@exodus/ethereum-api@8.64.6) (2026-02-23)
7
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.64.6",
3
+ "version": "8.64.7",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Ethereum and EVM-based blockchains",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -35,9 +35,10 @@
35
35
  "@exodus/ethereumjs": "^1.8.0",
36
36
  "@exodus/fetch": "^1.3.0",
37
37
  "@exodus/models": "^12.13.0",
38
- "@exodus/safe-string": "^1.2.1",
38
+ "@exodus/safe-string": "^1.4.0",
39
39
  "@exodus/simple-retry": "^0.0.6",
40
40
  "@exodus/solidity-contract": "^1.3.0",
41
+ "@exodus/traceparent": "^3.0.1",
41
42
  "@exodus/web3-ethereum-utils": "^4.6.0",
42
43
  "bn.js": "^5.2.1",
43
44
  "delay": "^4.0.1",
@@ -54,7 +55,7 @@
54
55
  "devDependencies": {
55
56
  "@exodus/assets-testing": "^1.0.0",
56
57
  "@exodus/bsc-meta": "^2.5.1",
57
- "@exodus/errors": "^3.3.0",
58
+ "@exodus/errors": "^3.8.0",
58
59
  "@exodus/ethereumarbone-meta": "^2.1.2",
59
60
  "@exodus/fantommainnet-meta": "^2.0.5",
60
61
  "@exodus/matic-meta": "^2.2.7",
@@ -67,5 +68,5 @@
67
68
  "type": "git",
68
69
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
70
  },
70
- "gitHead": "05a14c42a3963199e2b0b11d0a643884ebae8e46"
71
+ "gitHead": "7d25477c075c7cef416ffe2c7e6e4c99e2928bdc"
71
72
  }
package/src/ens/index.js CHANGED
@@ -64,4 +64,5 @@ const resolveEnsAddress = async (address, server) => {
64
64
  return resolver.decodeOutput({ data, method: 'name' })[0]
65
65
  }
66
66
 
67
+ // This doesn't seem to be used anywhere.
67
68
  export { resolveEnsAddress, resolveEnsName }
@@ -1,13 +1,277 @@
1
- export const reasons = {
2
- fetchGasLimitFailed: 'Fetch gas limit failed',
3
- nonceFetchFailed: 'Nonce fetch failed',
4
- balanceFetchFailed: 'Balance fetch failed',
5
- broadcastTxFailed: 'Broadcast tx failed',
6
- getTransactionByHashFailed: 'Get transaction by hash failed',
7
- ethCallErc20Failed: 'Eth call erc20 failed',
8
- insufficientFunds: 'Insufficient funds',
9
- bumpTxFailed: 'Bump tx failed',
10
- transactionUnderpriced: 'Transaction underpriced',
1
+ import { safeString } from '@exodus/safe-string'
2
+
3
+ // Error types for categorizing EVM errors
4
+ export const EVM_ERROR_TYPES = {
5
+ NODE_STATE_READ: safeString`NODE_STATE_READ`, // RPC-level read operations
6
+ CONTRACT_CALL: safeString`CONTRACT_CALL`, // Smart contract calls (eth_call, estimateGas)
7
+ BROADCAST: safeString`BROADCAST`, // Transaction broadcast errors (includes reverts)
8
+ }
9
+
10
+ // Operation-specific reasons
11
+ export const EVM_ERROR_REASONS = {
12
+ // RPC-level read operations
13
+ balanceFetchFailed: {
14
+ reason: safeString`balance fetch failed`,
15
+ type: EVM_ERROR_TYPES.NODE_STATE_READ,
16
+ },
17
+ nonceFetchFailed: {
18
+ reason: safeString`nonce fetch failed`,
19
+ type: EVM_ERROR_TYPES.NODE_STATE_READ,
20
+ },
21
+ getTransactionByHashFailed: {
22
+ reason: safeString`get transaction by hash failed`,
23
+ type: EVM_ERROR_TYPES.NODE_STATE_READ,
24
+ },
25
+ getCodeFailed: {
26
+ reason: safeString`get code failed`,
27
+ type: EVM_ERROR_TYPES.NODE_STATE_READ,
28
+ },
29
+ getGasPriceFailed: {
30
+ reason: safeString`get gas price failed`,
31
+ type: EVM_ERROR_TYPES.NODE_STATE_READ,
32
+ },
33
+ getBaseFeePerGasFailed: {
34
+ reason: safeString`get base fee per gas failed`,
35
+ type: EVM_ERROR_TYPES.NODE_STATE_READ,
36
+ },
37
+
38
+ // Generic smart contract call failure.
39
+ ethCallFailed: {
40
+ reason: safeString`eth call failed`,
41
+ type: EVM_ERROR_TYPES.CONTRACT_CALL,
42
+ },
43
+ ethCallErc20Failed: {
44
+ reason: safeString`eth call erc20 failed`,
45
+ type: EVM_ERROR_TYPES.CONTRACT_CALL,
46
+ },
47
+
48
+ // Gas Usage Simulation (checks contract reverts)
49
+ fetchGasLimitFailed: {
50
+ reason: safeString`fetch gas limit failed`,
51
+ type: EVM_ERROR_TYPES.CONTRACT_CALL,
52
+ },
53
+
54
+ // Write operations (checks sender account state/ability to transact)
55
+ // Generic failure, no info about a particular smart contract revert.
56
+ broadcastTxFailed: {
57
+ reason: safeString`broadcast tx failed`,
58
+ type: EVM_ERROR_TYPES.BROADCAST,
59
+ },
60
+
61
+ // Reversion reasons
62
+ executionReverted: {
63
+ reason: safeString`execution reverted`,
64
+ type: EVM_ERROR_TYPES.BROADCAST,
65
+ },
66
+ invalidOpcode: {
67
+ reason: safeString`invalid opcode`,
68
+ type: EVM_ERROR_TYPES.BROADCAST,
69
+ },
70
+ insufficientFundsForGas: {
71
+ reason: safeString`insufficient funds for gas * price + value`,
72
+ type: EVM_ERROR_TYPES.BROADCAST,
73
+ },
74
+ alreadyKnown: {
75
+ reason: safeString`already known`,
76
+ type: EVM_ERROR_TYPES.BROADCAST,
77
+ },
78
+ nonceTooLow: {
79
+ reason: safeString`nonce too low`,
80
+ type: EVM_ERROR_TYPES.BROADCAST,
81
+ },
82
+ insufficientBalance: {
83
+ reason: safeString`execution reverted: insufficient balance / transfer exceeds balance`,
84
+ type: EVM_ERROR_TYPES.BROADCAST,
85
+ },
86
+ replacementTransactionUnderpriced: {
87
+ reason: safeString`replacement transaction underpriced`,
88
+ type: EVM_ERROR_TYPES.BROADCAST,
89
+ },
90
+ zeroTransfer: {
91
+ reason: safeString`execution reverted: transfer amount must be greater than zero`,
92
+ type: EVM_ERROR_TYPES.BROADCAST,
93
+ },
94
+ mathError: {
95
+ reason: safeString`execution reverted: arithmetic overflow/underflow`,
96
+ type: EVM_ERROR_TYPES.BROADCAST,
97
+ },
98
+
99
+ transactionUnderpriced: {
100
+ reason: safeString`transaction underpriced`,
101
+ type: EVM_ERROR_TYPES.BROADCAST,
102
+ },
103
+ nonceTooHigh: {
104
+ reason: safeString`nonce too high`,
105
+ type: EVM_ERROR_TYPES.BROADCAST,
106
+ },
107
+ gasPriceBelowMin: {
108
+ reason: safeString`transaction gas price below minimum`,
109
+ type: EVM_ERROR_TYPES.BROADCAST,
110
+ },
111
+ insufficientGas: {
112
+ reason: safeString`insufficient gas`,
113
+ type: EVM_ERROR_TYPES.BROADCAST,
114
+ },
115
+ futureTransactionReplace: {
116
+ reason: safeString`future transaction tries to replace pending`,
117
+ type: EVM_ERROR_TYPES.BROADCAST,
118
+ },
119
+ eip155Required: {
120
+ reason: safeString`eip-155 replay protection required`,
121
+ type: EVM_ERROR_TYPES.BROADCAST,
122
+ },
123
+ invalidRlpSerialization: {
124
+ reason: safeString`invalid rlp serialization`,
125
+ type: EVM_ERROR_TYPES.BROADCAST,
126
+ },
127
+ transactionTypeNotSupported: {
128
+ reason: safeString`transaction type not supported`,
129
+ type: EVM_ERROR_TYPES.BROADCAST,
130
+ },
131
+ delegatedAccountError: {
132
+ reason: safeString`delegated account error`,
133
+ type: EVM_ERROR_TYPES.BROADCAST,
134
+ },
135
+ invalidJumpDestination: {
136
+ reason: safeString`invalid jump destination`,
137
+ type: EVM_ERROR_TYPES.BROADCAST,
138
+ },
139
+ }
140
+
141
+ // Patterns to normalize (group similar errors together)
142
+ const EVM_NORMALIZATION_PATTERNS = [
143
+ // Insufficient funds for gas (native currency, not tokens)
144
+ {
145
+ pattern: /(insufficient funds for gas \* price \+ value|insufficient funds(?!.*balance))/i,
146
+ errorInfo: EVM_ERROR_REASONS.insufficientFundsForGas,
147
+ },
148
+ // "nonce too low" or "nonce is too low" - tx nonce already used
149
+ {
150
+ pattern: /\bnonce\b.*\btoo low\b/i,
151
+ errorInfo: EVM_ERROR_REASONS.nonceTooLow,
152
+ },
153
+ // Comprehensive insufficient balance / transfer exceeds balance pattern (TOKEN balance issues)
154
+ // Catches: ERC20/BEP20, 0xe450d38c custom error, insufficient balance, balanceNotEnough,
155
+ // and token-specific "transfer amount exceeds balance" (BaseToken, SaveYourAssets, XVS, Ondo, FLOKI, etc.)
156
+ {
157
+ pattern:
158
+ /(0xe450d38c|insufficient.?balance|balance.?not.?enough|not enough fund|(erc20|bep20):.*transfer amount exceeds balance|\w+:.*transfer amount exceeds balance)/i,
159
+ errorInfo: EVM_ERROR_REASONS.insufficientBalance,
160
+ },
161
+ // Arithmetic overflow/underflow errors (SafeMath, Solidity 0.8+, etc.)
162
+ {
163
+ pattern: /(safemath:.*overflow|arithmetic underflow or overflow)/i,
164
+ errorInfo: EVM_ERROR_REASONS.mathError,
165
+ },
166
+ // Transfer amount must be greater than zero (various phrasings)
167
+ {
168
+ pattern:
169
+ /(transfer amount must be greater than zero|amount must be greater than 0|amt must be over than 0|zero_amount|transfer amount must be positive|transfer amount zero)/i,
170
+ errorInfo: EVM_ERROR_REASONS.zeroTransfer,
171
+ },
172
+ // Gas price below network minimum (not mempool competition, but protocol minimum)
173
+ // "gas price below minimum", "gas tip cap...minimum", "max fee per gas less than block base fee"
174
+ {
175
+ pattern:
176
+ /gas price below minimum|gas tip cap.*minimum|max fee per gas less than block base fee/i,
177
+ errorInfo: EVM_ERROR_REASONS.gasPriceBelowMin,
178
+ },
179
+ // Gas limit errors
180
+ // "intrinsic gas too low", "insufficient gas", "floor data gas cost" - gas limit below minimum required
181
+ {
182
+ pattern: /intrinsic gas too low|insufficient gas|floor data gas cost/i,
183
+ errorInfo: EVM_ERROR_REASONS.insufficientGas,
184
+ },
185
+ // "nonce too high" or "nonce is too high" - tx nonce too far ahead
186
+ {
187
+ pattern: /\bnonce\b.*\btoo high\b/i,
188
+ errorInfo: EVM_ERROR_REASONS.nonceTooHigh,
189
+ },
190
+ // Transaction type not supported
191
+ // "transaction type not supported", "tx type not supported" - chain doesn't support this tx type (e.g., EIP-1559)
192
+ {
193
+ pattern: /transaction type not supported|tx type not supported/i,
194
+ errorInfo: EVM_ERROR_REASONS.transactionTypeNotSupported,
195
+ },
196
+ // Invalid jump destination
197
+ {
198
+ pattern: /invalid jump destination/i,
199
+ errorInfo: EVM_ERROR_REASONS.invalidJumpDestination,
200
+ },
201
+ // Transaction already in mempool/chain
202
+ // "already known", "known transaction", "already imported", "already in mempool"
203
+ {
204
+ pattern: /already known|known transaction|already imported|already in mempool/i,
205
+ errorInfo: EVM_ERROR_REASONS.alreadyKnown,
206
+ },
207
+ // Replacement transaction underpriced (must come BEFORE "transaction underpriced")
208
+ {
209
+ pattern: /replacement transaction underpriced/i,
210
+ errorInfo: EVM_ERROR_REASONS.replacementTransactionUnderpriced,
211
+ },
212
+ // Transaction underpriced (checked after replacement to avoid double match)
213
+ {
214
+ pattern: /transaction underpriced/i,
215
+ errorInfo: EVM_ERROR_REASONS.transactionUnderpriced,
216
+ },
217
+ // Replay-protected transactions required (EIP-155 chain ID signing)
218
+ // "only replay-protected", "replay-protected transaction/tx required", "not replay protected", "invalid chain id"
219
+ {
220
+ pattern:
221
+ /only replay-protected|replay-protected (transaction|tx) required|not replay protected|invalid chain id/i,
222
+ errorInfo: EVM_ERROR_REASONS.eip155Required,
223
+ },
224
+ // Future transaction tries to replace pending (nonce conflict)
225
+ {
226
+ pattern: /future transaction.*replace.*pending/i,
227
+ errorInfo: EVM_ERROR_REASONS.futureTransactionReplace,
228
+ },
229
+ // Serialization/signature errors
230
+ // "rlp:", "invalid rlp", "decode rlp", "invalid sender", "invalid signature", "bad signature"
231
+ {
232
+ pattern: /rlp:|invalid rlp|decode rlp|invalid sender|invalid signature|bad signature/i,
233
+ errorInfo: EVM_ERROR_REASONS.invalidRlpSerialization,
234
+ },
235
+ // EIP-7702 delegation errors
236
+ // "delegated" (but not "delegatecall"), "eip-7702", "eip7702" - account delegation related errors
237
+ {
238
+ pattern: /\bdelegated\b(?!\s*call)|eip-?7702/i,
239
+ errorInfo: EVM_ERROR_REASONS.delegatedAccountError,
240
+ },
241
+ // GENERIC CATCH-ALL PATTERNS (must be last)
242
+ {
243
+ pattern: /^execution reverted$/i,
244
+ errorInfo: EVM_ERROR_REASONS.executionReverted,
245
+ },
246
+ {
247
+ pattern: /\binvalid opcode\b/i,
248
+ errorInfo: EVM_ERROR_REASONS.invalidOpcode,
249
+ },
250
+ {
251
+ pattern: /^execution reverted: 0x$/i,
252
+ errorInfo: EVM_ERROR_REASONS.executionReverted,
253
+ },
254
+ // Final catch-all for any remaining "execution reverted:" messages
255
+ {
256
+ pattern: /execution reverted:/i,
257
+ errorInfo: EVM_ERROR_REASONS.executionReverted,
258
+ },
259
+ ]
260
+
261
+ // This should be renamed something better
262
+ export function getEvmErrorReason(errorMessage) {
263
+ for (const { pattern, errorInfo } of EVM_NORMALIZATION_PATTERNS) {
264
+ if (pattern.test(errorMessage)) {
265
+ return {
266
+ reason: errorInfo.reason,
267
+ type: errorInfo.type,
268
+ wasNormalized: true,
269
+ patternUsed: pattern.toString(),
270
+ }
271
+ }
272
+ }
273
+
274
+ return null
11
275
  }
12
276
 
13
277
  const MAX_HINT_LENGTH = 100
@@ -19,15 +283,22 @@ export class EthLikeError extends Error {
19
283
  * Creates an instance of EthLikeError.
20
284
  *
21
285
  * @param {string} message - Standard error message.
22
- * @param {string} reason - A constant indicating the generic failure. Must not contain any sensitive information such as private keys, transaction IDs, or wallet addresses.
286
+ * @param {Object} errorReasonInfo - Object containing reason and type for the error.
287
+ * @param {string} errorReasonInfo.reason - A constant indicating the generic failure. Must not contain any sensitive information such as private keys, transaction IDs, or wallet addresses.
288
+ * @param {string} errorReasonInfo.type - A category for the error (e.g., 'NODE_STATE_READ', 'CONTRACT_CALL', 'BROADCAST').
23
289
  * @param {string} hint - A hint to help the user understand the error. Must not contain any sensitive information such as private keys, transaction IDs, or wallet addresses.
290
+ * @param {TraceId} [traceId] - Optional TraceId object from HTTP response 'traceparent' header for debugging.
291
+ * @param {string} [baseAssetName] - Optional base asset name (e.g. 'ethereum', 'bnbsmartchain') for identifying the network.
24
292
  */
25
- constructor({ message, reason, hint }) {
293
+ constructor({ message, errorReasonInfo, hint, traceId, baseAssetName }) {
26
294
  super(message)
27
- this.name = 'EthLikeError'
295
+ this.name = safeString`EthLikeError`
28
296
  this.#hintStack = [hint] // NOTE: we can add more hints to the stack
29
- this.reason = reason
297
+ this.reason = errorReasonInfo.reason
30
298
  this.hint = this.#extractHint(hint)
299
+ this.type = errorReasonInfo.type
300
+ this.traceId = traceId
301
+ this.baseAssetName = baseAssetName
31
302
  }
32
303
 
33
304
  addHint = (hint) => {
@@ -41,6 +312,15 @@ export class EthLikeError extends Error {
41
312
  return this
42
313
  }
43
314
 
315
+ getContext = () => {
316
+ return {
317
+ reason: this.reason,
318
+ type: this.type,
319
+ traceId: this.traceId,
320
+ baseAssetName: this.baseAssetName,
321
+ }
322
+ }
323
+
44
324
  // NOTE: a class method for now, if we move this to be a generic error for all assets, each asset will have its own implementation
45
325
  #extractHint = (hint) => {
46
326
  if (!hint) return ''
@@ -72,7 +352,7 @@ export class EthLikeError extends Error {
72
352
  }
73
353
  }
74
354
 
75
- export const withErrorReason = async (promise, errorReason, hint) => {
355
+ export const withErrorReason = async ({ promise, errorReasonInfo, hint, baseAssetName }) => {
76
356
  try {
77
357
  return await promise
78
358
  } catch (err) {
@@ -84,8 +364,10 @@ export const withErrorReason = async (promise, errorReason, hint) => {
84
364
 
85
365
  throw new EthLikeError({
86
366
  message: err.message,
87
- reason: errorReason,
367
+ errorReasonInfo,
88
368
  hint,
369
+ traceId: err.traceId,
370
+ baseAssetName,
89
371
  })
90
372
  }
91
373
  }
@@ -1,9 +1,10 @@
1
1
  import { memoizeLruCache } from '@exodus/asset-lib'
2
2
  import { ABI, isEthereumLikeAsset, isEthereumLikeToken, normalizeTxId } from '@exodus/ethereum-lib'
3
+ import { safeString } from '@exodus/safe-string'
3
4
  import SolidityContract from '@exodus/solidity-contract'
4
5
  import assert from 'minimalistic-assert'
5
6
 
6
- import * as ErrorWrapper from './error-wrapper.js'
7
+ import { EthLikeError, EVM_ERROR_REASONS, withErrorReason } from './error-wrapper.js'
7
8
  import { getServer, getServerByName } from './exodus-eth-server/index.js'
8
9
  import { fromHexToString } from './number-utils.js'
9
10
 
@@ -18,7 +19,12 @@ export async function isContract(baseAssetName, address) {
18
19
  }
19
20
 
20
21
  export async function isContractAddress({ asset, address }) {
21
- return getServer(asset).isContract(address)
22
+ return withErrorReason({
23
+ promise: getServer(asset).isContract(address),
24
+ errorReasonInfo: EVM_ERROR_REASONS.getCodeFailed,
25
+ hint: safeString`isContractAddress`,
26
+ baseAssetName: asset.baseAsset.name,
27
+ })
22
28
  }
23
29
 
24
30
  export const isContractAddressCached = memoizeLruCache(
@@ -29,7 +35,12 @@ export const isContractAddressCached = memoizeLruCache(
29
35
 
30
36
  export async function isForwarderContract({ asset, address }) {
31
37
  const server = getServer(asset)
32
- const contractCode = await server.getCode(address)
38
+ const contractCode = await withErrorReason({
39
+ promise: server.getCode(address),
40
+ errorReasonInfo: EVM_ERROR_REASONS.getCodeFailed,
41
+ hint: safeString`isForwarderContract`,
42
+ baseAssetName: asset.baseAsset.name,
43
+ })
33
44
  return contractCode === FORWARDER_CONTRACT_CODE
34
45
  }
35
46
 
@@ -41,21 +52,23 @@ export const isForwarderContractCached = memoizeLruCache(
41
52
 
42
53
  export async function getNonce({ asset, address, tag = 'latest' }) {
43
54
  const server = getServer(asset)
44
- const nonce = await ErrorWrapper.withErrorReason(
45
- server.getTransactionCount(address, tag),
46
- ErrorWrapper.reasons.nonceFetchFailed,
47
- 'getNonce'
48
- )
55
+ const nonce = await withErrorReason({
56
+ promise: server.getTransactionCount(address, tag),
57
+ errorReasonInfo: EVM_ERROR_REASONS.nonceFetchFailed,
58
+ hint: safeString`getNonce`,
59
+ baseAssetName: asset.baseAsset.name,
60
+ })
49
61
 
50
62
  return parseInt(nonce, 16)
51
63
  }
52
64
 
53
65
  export async function estimateGas({ asset, ...args }) {
54
- return ErrorWrapper.withErrorReason(
55
- getServer(asset).estimateGas(args),
56
- ErrorWrapper.reasons.fetchGasLimitFailed,
57
- 'estimateGas'
58
- )
66
+ return withErrorReason({
67
+ promise: getServer(asset).estimateGas(args),
68
+ errorReasonInfo: EVM_ERROR_REASONS.fetchGasLimitFailed,
69
+ hint: safeString`estimateGas`,
70
+ baseAssetName: asset.baseAsset.name,
71
+ })
59
72
  }
60
73
 
61
74
  // Only base assets, not tokens
@@ -63,7 +76,12 @@ export async function getBalance({ asset, address }) {
63
76
  if (!isEthereumLikeAsset(asset)) throw new Error(`unsupported asset ${asset.name}`)
64
77
 
65
78
  const server = getServer(asset)
66
- const balances = await server.getBalance(address)
79
+ const balances = await withErrorReason({
80
+ promise: server.getBalance(address),
81
+ errorReasonInfo: EVM_ERROR_REASONS.balanceFetchFailed,
82
+ hint: safeString`getBalance`,
83
+ baseAssetName: asset.baseAsset.name,
84
+ })
67
85
  return balances?.confirmed?.value || '0'
68
86
  }
69
87
 
@@ -71,16 +89,27 @@ export async function getBalance({ asset, address }) {
71
89
  export async function getBalanceProxied({ asset, address, tag = 'latest' }) {
72
90
  if (!isEthereumLikeAsset(asset)) throw new Error(`unsupported asset ${asset.name}`)
73
91
 
74
- const result = await getServer(asset).getBalanceProxied(address)
92
+ const result = await withErrorReason({
93
+ promise: getServer(asset).getBalanceProxied(address),
94
+ errorReasonInfo: EVM_ERROR_REASONS.balanceFetchFailed,
95
+ hint: safeString`getBalanceProxied`,
96
+ baseAssetName: asset.baseAsset.name,
97
+ })
75
98
  return fromHexToString(result)
76
99
  }
77
100
 
78
101
  // Only ETH-like assets with token support
102
+ // How would this return token balances? It does the same as getBalanceProxied... getting ETH balance
79
103
  export async function getTokenBalance({ asset, address }) {
80
104
  if (!isEthereumLikeToken(asset)) throw new Error(`unsupported ETH-like token ${asset.name}`)
81
105
 
82
106
  const server = getServer(asset)
83
- const balances = await server.getBalance(address)
107
+ const balances = await withErrorReason({
108
+ promise: server.getBalance(address),
109
+ errorReasonInfo: EVM_ERROR_REASONS.balanceFetchFailed,
110
+ hint: safeString`getTokenBalance`,
111
+ baseAssetName: asset.baseAsset.name,
112
+ })
84
113
  const contractAddress = asset.contract.address.toLowerCase()
85
114
  return balances?.confirmed?.[contractAddress] || '0'
86
115
  }
@@ -90,7 +119,12 @@ export async function getTokenBalanceFromNode({ asset, address }) {
90
119
 
91
120
  const server = getServer(asset)
92
121
  const contractAddress = asset.contract.address.toLowerCase()
93
- const balances = await server.balanceOf(address, contractAddress)
122
+ const balances = await withErrorReason({
123
+ promise: server.balanceOf(address, contractAddress),
124
+ errorReasonInfo: EVM_ERROR_REASONS.ethCallErc20Failed,
125
+ hint: safeString`getTokenBalanceFromNode`,
126
+ baseAssetName: asset.baseAsset.name,
127
+ })
94
128
  return balances?.confirmed?.[contractAddress] || '0'
95
129
  }
96
130
 
@@ -102,14 +136,24 @@ export function sendRawTransaction(asset) {
102
136
  export async function transactionExists({ asset, txId }) {
103
137
  const server = getServer(asset)
104
138
  txId = normalizeTxId(txId)
105
- const txResult = await server.getTransactionByHash(txId)
139
+ const txResult = await withErrorReason({
140
+ promise: server.getTransactionByHash(txId),
141
+ errorReasonInfo: EVM_ERROR_REASONS.getTransactionByHashFailed,
142
+ hint: safeString`transactionExists`,
143
+ baseAssetName: asset.baseAsset.name,
144
+ })
106
145
  return Boolean(txResult && txResult.hash === txId)
107
146
  }
108
147
 
109
148
  export async function getTransaction({ asset, txId }) {
110
149
  const server = getServer(asset)
111
150
  txId = normalizeTxId(txId)
112
- return server.getTransactionByHash(txId)
151
+ return withErrorReason({
152
+ promise: server.getTransactionByHash(txId),
153
+ errorReasonInfo: EVM_ERROR_REASONS.getTransactionByHashFailed,
154
+ hint: safeString`getTransaction`,
155
+ baseAssetName: asset.baseAsset.name,
156
+ })
113
157
  }
114
158
 
115
159
  export const getIsForwarderContract = memoizeLruCache(
@@ -140,18 +184,12 @@ export const getERC20Params = async ({ asset, address, paramNames = DEFAULT_PARA
140
184
  try {
141
185
  callResponse = await server.ethCall({ to: address, data: ERC20[method].methodId })
142
186
  } catch (err) {
143
- if (err.message === 'execution reverted') {
144
- throw new ErrorWrapper.EthLikeError({
145
- message: `Can't find parameters for contract with address ${address}. Are you sure it is a valid ERC20 contract?`,
146
- reason: ErrorWrapper.reasons.ethCallErc20Failed,
147
- hint: 'ethCall:executionReverted',
148
- })
149
- }
150
-
151
- throw new ErrorWrapper.EthLikeError({
187
+ throw new EthLikeError({
152
188
  message: err.message,
153
- reason: ErrorWrapper.reasons.ethCallErc20Failed,
154
- hint: 'ethCall',
189
+ errorReasonInfo: EVM_ERROR_REASONS.ethCallErc20Failed,
190
+ hint: safeString`getERC20Params`,
191
+ traceId: err.traceId,
192
+ baseAssetName: asset.baseAsset.name,
155
193
  })
156
194
  }
157
195
 
@@ -181,7 +219,12 @@ export const getERC20Params = async ({ asset, address, paramNames = DEFAULT_PARA
181
219
  }
182
220
 
183
221
  export async function getEIP7702Delegation({ address, server }) {
184
- const code = await server.getCode(address)
222
+ const code = await withErrorReason({
223
+ promise: server.getCode(address),
224
+ errorReasonInfo: EVM_ERROR_REASONS.getCodeFailed,
225
+ hint: safeString`getEIP7702Delegation`,
226
+ baseAssetName: server.baseAssetName,
227
+ })
185
228
 
186
229
  // No code at all
187
230
  if (!code || code === '0x' || code === '0x0') {
@@ -11,8 +11,9 @@ import { getFallbackGasPriceEstimation } from './utils.js'
11
11
  const { isEmpty } = lodash
12
12
 
13
13
  export default class ApiCoinNodesServer extends EventEmitter {
14
- constructor({ uri }) {
14
+ constructor({ baseAssetName, uri }) {
15
15
  super()
16
+ this.baseAssetName = baseAssetName
16
17
  this.uri = uri
17
18
  this.defaultUri = uri
18
19
  this.id = 0
@@ -1,4 +1,5 @@
1
1
  import { retry } from '@exodus/simple-retry'
2
+ import { TraceId } from '@exodus/traceparent'
2
3
  import assert from 'minimalistic-assert'
3
4
 
4
5
  import ClarityServer, { RPC_REQUEST_TIMEOUT } from './clarity.js'
@@ -43,14 +44,31 @@ const fetchJson = async (url, fetchOptions) => {
43
44
  const response = await fetch(url, fetchOptions)
44
45
 
45
46
  if (!response.ok) {
46
- throw new Error(
47
+ const traceId = TraceId.fromResponse(response)
48
+ const error = new Error(
47
49
  `${url} returned ${response.status}: ${
48
50
  response.statusText || 'Unknown Status Text'
49
51
  }. Body: ${await getTextFromResponse(response)}`
50
52
  )
53
+ if (traceId) {
54
+ error.traceId = traceId
55
+ }
56
+
57
+ throw error
58
+ }
59
+
60
+ const json = await response.json()
61
+
62
+ // Only capture trace ID if there's an RPC error in the response
63
+ // (handleJsonRPCResponse will extract it when throwing the error)
64
+ if (json.error) {
65
+ const traceId = TraceId.fromResponse(response)
66
+ if (traceId) {
67
+ json.__traceId = traceId
68
+ }
51
69
  }
52
70
 
53
- return response.json()
71
+ return json
54
72
  }
55
73
 
56
74
  async function fetchJsonRetry(url, fetchOptions) {
@@ -126,9 +144,6 @@ export default class ClarityServerV2 extends ClarityServer {
126
144
  // See: https://github.com/ExodusMovement/clarity/blob/d3c2a7f501a4391da630592bca3bf57c3ddd5e89/src/modules/ethereum-like/gas-price/index.js#L192C5-L219C6
127
145
  return await this.getGasPriceEstimation()
128
146
  } catch {
129
- console.log(
130
- `failed to query ${this.baseAssetName} gas-price-estimation endpoint, falling back to websocket`
131
- )
132
147
  // HACK: The `getGasPriceEstimation` endpoint is not guaranteed
133
148
  // to exist for all assets. In this case, we'll fallback
134
149
  // to legacy behaviour, which is to query via the WebSocket.
@@ -185,8 +200,9 @@ export default class ClarityServerV2 extends ClarityServer {
185
200
  }
186
201
  }
187
202
 
188
- fetchRpcHttpRequest = ({ baseApiPath, body }) =>
189
- fetchHttpRequest({ baseApiPath, path: '/rpc', method: 'POST', body })
203
+ fetchRpcHttpRequest = ({ baseApiPath, body }) => {
204
+ return fetchHttpRequest({ baseApiPath, path: '/rpc', method: 'POST', body })
205
+ }
190
206
 
191
207
  async sendRpcRequest(rpcRequest) {
192
208
  try {
@@ -201,6 +217,8 @@ export default class ClarityServerV2 extends ClarityServer {
201
217
  }
202
218
  }
203
219
 
220
+ // Maybe some functions from clarity should be overriden to have handleJsonRPCResponse as well
221
+
204
222
  async sendRawTransaction(...params) {
205
223
  const { baseApiPath } = this
206
224
  const request = this.sendRawTransactionRequest(...params)
@@ -1,6 +1,7 @@
1
1
  import { bufferToHex } from '@exodus/ethereumjs/util'
2
2
  import { safeString } from '@exodus/safe-string'
3
3
  import SolidityContract from '@exodus/solidity-contract'
4
+ import { TraceId } from '@exodus/traceparent'
4
5
  import EventEmitter from 'events/events.js'
5
6
  import io from 'socket.io-client'
6
7
 
@@ -104,12 +105,20 @@ export default class ClarityServer extends EventEmitter {
104
105
 
105
106
  const revisedError = new Error(`Bad rpc response: ${message}`)
106
107
  revisedError.hint = safeString`Bad rpc response: ${errorMessageToSafeHint(message)}`
108
+
109
+ // Preserve trace ID: from HTTP (__traceId) or WebSocket (traceparent in JSON body)
110
+ const traceId = response?.__traceId || response?.traceparent
111
+ if (traceId) {
112
+ revisedError.traceId = traceId
113
+ }
114
+
107
115
  throw revisedError
108
116
  }
109
117
 
110
118
  return result
111
119
  }
112
120
 
121
+ // Transport: WS only in ClarityServer, HTTP only in ClarityServerV2 (overridden)
113
122
  async getAllTransactions(params) {
114
123
  const transactions = { pending: [], confirmed: [] }
115
124
  const cursor = await this.getTransactions({
@@ -129,6 +138,7 @@ export default class ClarityServer extends EventEmitter {
129
138
  return { cursor, transactions }
130
139
  }
131
140
 
141
+ // Transport: WS only (uses transactions socket, not RPC socket)
132
142
  async getTransactions({ walletAccount, address, cursor, onChunk }) {
133
143
  const socket = this.connectTransactions({ walletAccount, address })
134
144
  const listener = (isPending, chunk, callback) => {
@@ -149,6 +159,7 @@ export default class ClarityServer extends EventEmitter {
149
159
  .finally(() => socket.off('transactionsChunk', listener))
150
160
  }
151
161
 
162
+ // Transport: WS only (uses fee socket)
152
163
  getFeeFromWebSocket() {
153
164
  const socket = this.connectFee()
154
165
  return new Promise((resolve, reject) => {
@@ -165,10 +176,12 @@ export default class ClarityServer extends EventEmitter {
165
176
  })
166
177
  }
167
178
 
179
+ // Transport: WS only in ClarityServer, HTTP first → WS fallback in ClarityServerV2 (overridden)
168
180
  async getFee() {
169
181
  return this.getFeeFromWebSocket()
170
182
  }
171
183
 
184
+ // Transport: Depends on getFee() - WS only in ClarityServer, HTTP first → WS fallback in ClarityServerV2
172
185
  // for fee monitors
173
186
  async getGasPrice() {
174
187
  const fee = await this.getFee()
@@ -185,11 +198,18 @@ export default class ClarityServer extends EventEmitter {
185
198
  const timeout = setTimeout(() => reject(new Error(RPC_REQUEST_TIMEOUT)), 3000)
186
199
  rpcSocket.emit('request', rpcRequest, (response) => {
187
200
  clearTimeout(timeout)
201
+ const rawTraceparent = response?.traceparent || response?.[0]?.traceparent
202
+ if (rawTraceparent) {
203
+ // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
204
+ response.traceparent = TraceId.fromRawTraceparent(rawTraceparent)
205
+ }
206
+
188
207
  resolve(response)
189
208
  })
190
209
  })
191
210
  }
192
211
 
212
+ // Transport: WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
193
213
  async sendBatchRequest(batch) {
194
214
  const responses = await this.sendRpcRequest(batch)
195
215
  // FIXME: this falls apart if responses is not an array
@@ -206,12 +226,14 @@ export default class ClarityServer extends EventEmitter {
206
226
  return batch.map((request) => keyed[`${request.id}`])
207
227
  }
208
228
 
229
+ // Transport: Uses sendRpcRequest - WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
209
230
  async sendRequest(request) {
210
231
  const response = await this.sendRpcRequest(request)
211
232
 
212
233
  return this.handleJsonRPCResponse(response)
213
234
  }
214
235
 
236
+ // Transport: Via getCode → sendRequest → WS first → HTTP fallback in ClarityServerV2
215
237
  async isContract(address) {
216
238
  const code = await this.getCode(address)
217
239
  return code.length > 2
@@ -327,11 +349,13 @@ export default class ClarityServer extends EventEmitter {
327
349
  })
328
350
  }
329
351
 
352
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
330
353
  async proxyToCoinNode(params) {
331
354
  const request = this.buildRequest(params)
332
355
  return this.sendRequest(request)
333
356
  }
334
357
 
358
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
335
359
  async balanceOf(address, tokenAddress, tag = 'latest') {
336
360
  const request = this.balanceOfRequest(address, tokenAddress, tag)
337
361
  const result = await this.sendRequest(request)
@@ -343,69 +367,83 @@ export default class ClarityServer extends EventEmitter {
343
367
  }
344
368
  }
345
369
 
370
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
346
371
  async getBalance(...params) {
347
- const request = this.getBalanceRequest(...params)
372
+ const request = this.getBalanceRequest(...params) // eth_getBalance
348
373
  return this.sendRequest(request)
349
374
  }
350
375
 
376
+ // Transport: Via getBalance → sendRequest → WS first → HTTP fallback in ClarityServerV2
351
377
  async getBalanceProxied(...params) {
352
- return this.getBalance(...params)
378
+ return this.getBalance(...params) // eth_getBalance
353
379
  }
354
380
 
381
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
355
382
  async gasPrice(...params) {
356
383
  const request = this.gasPriceRequest(...params)
357
384
  return this.sendRequest(request)
358
385
  }
359
386
 
387
+ // Transport: WS only in ClarityServer, HTTP only in ClarityServerV2 (overridden)
360
388
  async estimateGas(...params) {
361
389
  const request = this.estimateGasRequest(...params)
362
390
  return this.sendRequest(request)
363
391
  }
364
392
 
393
+ // Transport: WS only in ClarityServer, HTTP only in ClarityServerV2 (overridden)
365
394
  async sendRawTransaction(...params) {
366
395
  const request = this.sendRawTransactionRequest(...params)
367
396
  return this.sendRequest(request)
368
397
  }
369
398
 
399
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
370
400
  async getCode(...params) {
371
401
  const request = this.getCodeRequest(...params)
372
402
  return this.sendRequest(request)
373
403
  }
374
404
 
405
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
375
406
  async getStorageAt(...params) {
376
407
  const request = this.getStorageAtRequest(...params)
377
408
  return this.sendRequest(request)
378
409
  }
379
410
 
411
+ // Transport: WS only in ClarityServer, HTTP only in ClarityServerV2 (overridden)
380
412
  async getTransactionCount(...params) {
381
413
  const request = this.getTransactionCountRequest(...params)
382
414
  return this.sendRequest(request)
383
415
  }
384
416
 
417
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
385
418
  async getTransactionByHash(...params) {
386
419
  const request = this.getTransactionByHashRequest(...params)
387
420
  return this.sendRequest(request)
388
421
  }
389
422
 
423
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
390
424
  async getTransactionReceipt(...params) {
391
425
  const request = this.getTransactionReceiptRequest(...params)
392
426
  return this.sendRequest(request)
393
427
  }
394
428
 
429
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
395
430
  async ethCall(...params) {
396
431
  const request = this.ethCallRequest(...params)
397
432
  return this.sendRequest(request)
398
433
  }
399
434
 
435
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
400
436
  async blockNumber(...params) {
401
437
  const request = this.blockNumberRequest(...params)
402
438
  return this.sendRequest(request)
403
439
  }
404
440
 
441
+ // Transport: Via getBlockByNumber → sendRequest → WS first → HTTP fallback in ClarityServerV2
405
442
  async getLatestBlock() {
406
443
  return this.getBlockByNumber('latest')
407
444
  }
408
445
 
446
+ // Transport: Via getLatestBlock → getBlockByNumber → sendRequest → WS first → HTTP fallback in ClarityServerV2
409
447
  async getBaseFeePerGas() {
410
448
  const response = await this.getLatestBlock()
411
449
  if (response.baseFeePerGas) {
@@ -413,46 +451,55 @@ export default class ClarityServer extends EventEmitter {
413
451
  }
414
452
  }
415
453
 
454
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
416
455
  async getBlockByHash(...params) {
417
456
  const request = this.getBlockByHashRequest(...params)
418
457
  return this.sendRequest(request)
419
458
  }
420
459
 
460
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
421
461
  async getBlockTransactionCountByNumber(...params) {
422
462
  const request = this.getBlockTransactionCountByNumberRequest(...params)
423
463
  return this.sendRequest(request)
424
464
  }
425
465
 
466
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
426
467
  async getBlockByNumber(...params) {
427
468
  const request = this.getBlockByNumberRequest(...params)
428
469
  return this.sendRequest(request)
429
470
  }
430
471
 
472
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
431
473
  async simulateV1(...params) {
432
474
  const request = this.simulateV1Request(...params)
433
475
  return this.sendRequest(request)
434
476
  }
435
477
 
478
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
436
479
  async simulateRawTransaction(...params) {
437
480
  const request = this.simulateRawTransactionRequest(...params)
438
481
  return this.sendRequest(request)
439
482
  }
440
483
 
484
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
441
485
  async getCoinbase() {
442
486
  const request = this.coinbaseRequest()
443
487
  return this.sendRequest(request)
444
488
  }
445
489
 
490
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
446
491
  async getCompilers() {
447
492
  const request = this.getCompilersRequest()
448
493
  return this.sendRequest(request)
449
494
  }
450
495
 
496
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
451
497
  async getNetVersion() {
452
498
  const request = this.getNetVersion()
453
499
  return this.sendRequest(request)
454
500
  }
455
501
 
502
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
456
503
  async getLogs(...params) {
457
504
  const request = this.getLogsRequest(...params)
458
505
  return this.sendRequest(request)
@@ -22,7 +22,7 @@ export function createEvmServer({ assetName, serverUrl, monitorType }) {
22
22
  assert(monitorType, 'monitorType is required')
23
23
  switch (monitorType) {
24
24
  case 'no-history':
25
- return new ApiCoinNodesServer({ uri: serverUrl })
25
+ return new ApiCoinNodesServer({ baseAssetName: assetName, uri: serverUrl })
26
26
  case 'clarity':
27
27
  return new ClarityServer({ baseAssetName: assetName, uri: serverUrl })
28
28
  case 'clarity-v2':
package/src/index.js CHANGED
@@ -88,7 +88,13 @@ export {
88
88
  fromHexToBN,
89
89
  } from './number-utils.js'
90
90
 
91
- export { reasons as errorReasons, withErrorReason, EthLikeError } from './error-wrapper.js'
91
+ export {
92
+ EVM_ERROR_REASONS,
93
+ EVM_ERROR_TYPES,
94
+ getEvmErrorReason,
95
+ withErrorReason,
96
+ EthLikeError,
97
+ } from './error-wrapper.js'
92
98
 
93
99
  export { txSendFactory } from './tx-send/index.js'
94
100
 
package/src/nft-utils.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { safeString } from '@exodus/safe-string'
1
2
  import SolidityContract from '@exodus/solidity-contract'
2
3
  import assert from 'minimalistic-assert'
3
4
 
4
- import * as ErrorWrapper from './error-wrapper.js'
5
+ import { EthLikeError, EVM_ERROR_REASONS } from './error-wrapper.js'
5
6
  import { fetchGasLimit } from './gas-estimation.js'
6
7
 
7
8
  export const getNftArguments = async ({ asset, nft, fromAddress, toAddress }) => {
@@ -44,10 +45,11 @@ export const getNftArguments = async ({ asset, nft, fromAddress, toAddress }) =>
44
45
  }
45
46
  })
46
47
  ).catch((e) => {
47
- throw new ErrorWrapper.EthLikeError({
48
+ throw new EthLikeError({
48
49
  message: errors.join('\n'),
49
- reason: ErrorWrapper.reasons.fetchGasLimitFailed,
50
- hint: 'getNftArguments',
50
+ errorReasonInfo: EVM_ERROR_REASONS.fetchGasLimitFailed,
51
+ hint: safeString`getNftArguments`,
52
+ traceId: e.traceId,
51
53
  })
52
54
  })
53
55
  return {
@@ -1,7 +1,9 @@
1
1
  import { createContract, createEthereumJsTx } from '@exodus/ethereum-lib'
2
2
  import { bufferToHex } from '@exodus/ethereumjs/util'
3
+ import { safeString } from '@exodus/safe-string'
3
4
  import assert from 'minimalistic-assert'
4
5
 
6
+ import { EVM_ERROR_REASONS, withErrorReason } from '../error-wrapper.js'
5
7
  import { fromHexToBigInt } from '../number-utils.js'
6
8
 
7
9
  export const estimateL1DataFeeFactory = ({ l1GasOracleAddress, server }) => {
@@ -14,7 +16,12 @@ export const estimateL1DataFeeFactory = ({ l1GasOracleAddress, server }) => {
14
16
  const callData = gasContract.getL1Fee.build(serialized)
15
17
  const buffer = Buffer.from(callData)
16
18
  const data = bufferToHex(buffer)
17
- const hex = await server.ethCall({ to: l1GasOracleAddress, data }, 'latest')
19
+ const hex = await withErrorReason({
20
+ promise: server.ethCall({ to: l1GasOracleAddress, data }, 'latest'),
21
+ errorReasonInfo: EVM_ERROR_REASONS.ethCallFailed,
22
+ hint: safeString`estimateL1DataFee`,
23
+ baseAssetName: server.baseAssetName,
24
+ })
18
25
  const l1DataFee = fromHexToBigInt(hex)
19
26
  const padFee = l1DataFee / BigInt(4)
20
27
  const maxL1DataFee = l1DataFee + padFee
@@ -1,7 +1,9 @@
1
1
  import { createContract } from '@exodus/ethereum-lib'
2
2
  import { bufferToHex } from '@exodus/ethereumjs/util'
3
+ import { safeString } from '@exodus/safe-string'
3
4
  import { retry } from '@exodus/simple-retry'
4
5
 
6
+ import { EVM_ERROR_REASONS, withErrorReason } from '../../error-wrapper.js'
5
7
  import { getServerByName } from '../../exodus-eth-server/index.js'
6
8
 
7
9
  // TODO: Shouldn't this be a function of `ethereumStakingState.minDelegateAmount`?
@@ -78,7 +80,12 @@ export class EthereumStaking {
78
80
  if (typeof from === 'string' && from.length > 0) data.from = from
79
81
 
80
82
  const eth = this.server || getServerByName(this.asset.name)
81
- return retry((...args) => eth.ethCall(...args), { delayTimesMs: RETRY_DELAYS })(data)
83
+ return withErrorReason({
84
+ promise: retry((...args) => eth.ethCall(...args), { delayTimesMs: RETRY_DELAYS })(data),
85
+ errorReasonInfo: EVM_ERROR_REASONS.ethCallFailed,
86
+ hint: safeString`stakingEthReadFunctionContract`,
87
+ baseAssetName: eth.baseAssetName,
88
+ })
82
89
  }
83
90
 
84
91
  // === ACCOUNTING ===
@@ -1,9 +1,11 @@
1
1
  import { createContract } from '@exodus/ethereum-lib'
2
2
  import ethAssets from '@exodus/ethereum-meta'
3
3
  import { bufferToHex } from '@exodus/ethereumjs/util'
4
+ import { safeString } from '@exodus/safe-string'
4
5
  import { retry } from '@exodus/simple-retry'
5
6
  import BN from 'bn.js'
6
7
 
8
+ import { EVM_ERROR_REASONS, withErrorReason } from '../../error-wrapper.js'
7
9
  import { getServerByName } from '../../exodus-eth-server/index.js'
8
10
  import { fromHexToBN, fromHexToString, splitIn32BytesArray } from '../../number-utils.js'
9
11
 
@@ -43,7 +45,12 @@ export class MaticStakingApi {
43
45
  }
44
46
 
45
47
  const eth = this.server || getServerByName('ethereum')
46
- return retry((...args) => eth.ethCall(...args), { delayTimesMs: RETRY_DELAYS })(data)
48
+ return withErrorReason({
49
+ promise: retry((...args) => eth.ethCall(...args), { delayTimesMs: RETRY_DELAYS })(data),
50
+ errorReasonInfo: EVM_ERROR_REASONS.ethCallFailed,
51
+ hint: safeString`stakingMaticReadFunctionContract`,
52
+ baseAssetName: eth.baseAssetName,
53
+ })
47
54
  }
48
55
 
49
56
  getWithdrawalDelay = async () => {
@@ -0,0 +1,58 @@
1
+ import { EthLikeError, EVM_ERROR_REASONS, getEvmErrorReason } from '../error-wrapper.js'
2
+ import { transactionExists } from '../eth-like-util.js'
3
+
4
+ /**
5
+ * Handles broadcast errors by parsing the error message and either throwing
6
+ * an appropriate EthLikeError or returning info for retry logic.
7
+ *
8
+ * @param {Error} err - The error from broadcastTx.
9
+ * @param {Object} options - Context for error handling.
10
+ * @param {Object} options.asset - The asset.
11
+ * @param {string} options.txId - The transaction ID.
12
+ * @param {boolean} options.isHardware - Whether this is a hardware wallet.
13
+ * @param {string} options.hint - Hint for the error.
14
+ * @returns {Promise<{ shouldRetry: boolean }>} - Returns if nonce too low and can retry.
15
+ * @throws {EthLikeError} - Throws for all other error cases.
16
+ */
17
+ export const handleBroadcastError = async (err, { asset, txId, isHardware, hint, isBumpTx }) => {
18
+ const message = err.message
19
+
20
+ const errorInfo = getEvmErrorReason(message) || EVM_ERROR_REASONS.broadcastTxFailed
21
+
22
+ const isNonceTooLow = errorInfo.reason === EVM_ERROR_REASONS.nonceTooLow.reason
23
+ const isAmbiguousError =
24
+ isNonceTooLow || errorInfo.reason === EVM_ERROR_REASONS.transactionUnderpriced.reason
25
+
26
+ if (errorInfo.reason === EVM_ERROR_REASONS.alreadyKnown.reason) {
27
+ console.info('tx already broadcast')
28
+ return { shouldRetry: false }
29
+ }
30
+
31
+ let txAlreadyExists = false
32
+ if (isAmbiguousError) {
33
+ try {
34
+ txAlreadyExists = await transactionExists({ asset, txId })
35
+ } catch (verifyErr) {
36
+ // Can't verify - fall through to original error handling
37
+ console.warn('Could not verify tx existence:', verifyErr.message)
38
+ }
39
+ }
40
+
41
+ if (txAlreadyExists) {
42
+ console.info('tx already broadcast')
43
+ return { shouldRetry: false }
44
+ }
45
+
46
+ // NOTE: Don't auto-retry nonce repair for bump/replacement txs.
47
+ // A replacement must keep the *same nonce* as the tx it's replacing.
48
+ // If we "fix" a bump tx by advancing the nonce, we create a brand-new tx instead of replacing the pending one.
49
+ if (isNonceTooLow && !isHardware && !isBumpTx) return { shouldRetry: true }
50
+
51
+ throw new EthLikeError({
52
+ message: err.message,
53
+ errorReasonInfo: errorInfo,
54
+ hint,
55
+ traceId: err.traceId,
56
+ baseAssetName: asset.baseAsset.name,
57
+ })
58
+ }
@@ -1,10 +1,10 @@
1
1
  import { normalizeTxId, parseUnsignedTx, updateNonce } from '@exodus/ethereum-lib'
2
+ import { safeString } from '@exodus/safe-string'
2
3
  import assert from 'minimalistic-assert'
3
4
 
4
- import * as ErrorWrapper from '../error-wrapper.js'
5
- import { transactionExists } from '../eth-like-util.js'
6
5
  import { getOptimisticTxLogEffects } from '../tx-log/index.js'
7
6
  import { ARBITRARY_ADDRESS } from '../tx-type/index.js'
7
+ import { handleBroadcastError } from './broadcast-error-handler.js'
8
8
 
9
9
  const txSendFactory = ({ assetClientInterface, createTx }) => {
10
10
  assert(assetClientInterface, 'assetClientInterface is required')
@@ -38,6 +38,7 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
38
38
  `The receiving wallet address must not be ${ARBITRARY_ADDRESS}`
39
39
  )
40
40
 
41
+ // Are there any signin-level errors that should be caught here?
41
42
  let { txId, rawTx } = await signTx({
42
43
  asset,
43
44
  unsignedTx,
@@ -47,41 +48,16 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
47
48
  try {
48
49
  await baseAsset.api.broadcastTx(rawTx.toString('hex'))
49
50
  } catch (err) {
50
- const transactionUnderpricedErr = err.message.match(/transaction underpriced/i)
51
- const nonceTooLowErr = err.message.match(/nonce (is |)too low/i)
52
- const insufficientFundsErr = err.message.match(/insufficient funds/i)
53
-
54
- // NOTE: We've found that `geth` can return the following errors
55
- // for transactions which may be already known. In this
56
- // case, we validate that the transaction is known to the
57
- // network.
58
- const txAlreadyExists =
59
- nonceTooLowErr || transactionUnderpricedErr
60
- ? await transactionExists({ asset, txId })
61
- : err.message.match(/already known/i) ||
62
- err.message.match(/transaction already imported/i)
63
-
64
- if (txAlreadyExists) {
65
- console.info('tx already broadcast') // inject logger factory from platform
66
- } else if (insufficientFundsErr) {
67
- throw new ErrorWrapper.EthLikeError({
68
- message: err.message,
69
- reason: ErrorWrapper.reasons.insufficientFunds,
70
- hint: 'broadcastTx',
71
- })
72
- } else if (unsignedTx.txMeta.bumpTxId) {
73
- throw new ErrorWrapper.EthLikeError({
74
- message: err.message,
75
- reason: ErrorWrapper.reasons.bumpTxFailed,
76
- hint: 'broadcastTx',
77
- })
78
- } else if (!nonceTooLowErr) {
79
- throw new ErrorWrapper.EthLikeError({
80
- message: err.message,
81
- reason: ErrorWrapper.reasons.broadcastTxFailed,
82
- hint: 'otherErr:broadcastTx',
83
- })
84
- } else if (nonceTooLowErr && !unsignedTx.txMeta.isHardware) {
51
+ const isBumpTx = !!unsignedTx.txMeta.bumpTxId
52
+ const { shouldRetry } = await handleBroadcastError(err, {
53
+ asset,
54
+ txId,
55
+ isHardware: !!unsignedTx.txMeta.isHardware,
56
+ hint: isBumpTx ? safeString`bumpTx` : safeString`broadcastTx`,
57
+ isBumpTx,
58
+ })
59
+
60
+ if (shouldRetry) {
85
61
  console.info('trying to send again...') // inject logger factory from platform
86
62
 
87
63
  // let's try to fix the nonce issue
@@ -92,10 +68,9 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
92
68
  forceFromNode: true,
93
69
  })
94
70
 
95
- // TODO: We should only do this for non-replacement transactions.
96
71
  const triedNonce = parsedTx.nonce
97
-
98
- if (typeof triedNonce === 'number') {
72
+ // Defensive: avoid retrying the same nonce if node nonce is stale (non-replacement sends only).
73
+ if (!isBumpTx && typeof triedNonce === 'number') {
99
74
  newNonce = Math.max(newNonce, triedNonce + 1)
100
75
  }
101
76
 
@@ -121,29 +96,15 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
121
96
 
122
97
  try {
123
98
  await baseAsset.api.broadcastTx(rawTx.toString('hex'))
124
- } catch (err) {
125
- const insufficientFundsErr = err.message.match(/insufficient funds/i)
126
- if (insufficientFundsErr) {
127
- throw new ErrorWrapper.EthLikeError({
128
- message: err.message,
129
- reason: ErrorWrapper.reasons.insufficientFunds,
130
- hint: 'retry:broadcastTx',
131
- })
132
- }
133
-
134
- throw new ErrorWrapper.EthLikeError({
135
- message: err.message,
136
- reason: ErrorWrapper.reasons.broadcastTxFailed,
137
- hint: 'retry:broadcastTx',
99
+ } catch (retryErr) {
100
+ await handleBroadcastError(retryErr, {
101
+ asset,
102
+ txId,
103
+ isHardware: false,
104
+ hint: safeString`retry:broadcastTx`,
105
+ isBumpTx: false, // retry path is only for non-bump anyway
138
106
  })
139
107
  }
140
- } else {
141
- // If none of the above apply, terminate with a general error.
142
- throw new ErrorWrapper.EthLikeError({
143
- message: err.message,
144
- reason: ErrorWrapper.reasons.broadcastTxFailed,
145
- hint: 'broadcastTx',
146
- })
147
108
  }
148
109
  }
149
110