@exodus/ethereum-api 8.64.5 → 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.
@@ -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,11 +1,13 @@
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
 
7
8
  import { fromHexToString } from '../number-utils.js'
8
9
  import { errorMessageToSafeHint } from './errors.js'
10
+ import { getFallbackGasPriceEstimation } from './utils.js'
9
11
 
10
12
  export const RPC_REQUEST_TIMEOUT = 'RPC_REQUEST_TIMEOUT'
11
13
 
@@ -103,12 +105,20 @@ export default class ClarityServer extends EventEmitter {
103
105
 
104
106
  const revisedError = new Error(`Bad rpc response: ${message}`)
105
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
+
106
115
  throw revisedError
107
116
  }
108
117
 
109
118
  return result
110
119
  }
111
120
 
121
+ // Transport: WS only in ClarityServer, HTTP only in ClarityServerV2 (overridden)
112
122
  async getAllTransactions(params) {
113
123
  const transactions = { pending: [], confirmed: [] }
114
124
  const cursor = await this.getTransactions({
@@ -128,6 +138,7 @@ export default class ClarityServer extends EventEmitter {
128
138
  return { cursor, transactions }
129
139
  }
130
140
 
141
+ // Transport: WS only (uses transactions socket, not RPC socket)
131
142
  async getTransactions({ walletAccount, address, cursor, onChunk }) {
132
143
  const socket = this.connectTransactions({ walletAccount, address })
133
144
  const listener = (isPending, chunk, callback) => {
@@ -148,6 +159,7 @@ export default class ClarityServer extends EventEmitter {
148
159
  .finally(() => socket.off('transactionsChunk', listener))
149
160
  }
150
161
 
162
+ // Transport: WS only (uses fee socket)
151
163
  getFeeFromWebSocket() {
152
164
  const socket = this.connectFee()
153
165
  return new Promise((resolve, reject) => {
@@ -164,27 +176,40 @@ export default class ClarityServer extends EventEmitter {
164
176
  })
165
177
  }
166
178
 
179
+ // Transport: WS only in ClarityServer, HTTP first → WS fallback in ClarityServerV2 (overridden)
167
180
  async getFee() {
168
181
  return this.getFeeFromWebSocket()
169
182
  }
170
183
 
184
+ // Transport: Depends on getFee() - WS only in ClarityServer, HTTP first → WS fallback in ClarityServerV2
171
185
  // for fee monitors
172
186
  async getGasPrice() {
173
187
  const fee = await this.getFee()
174
188
  return fee?.gasPrice
175
189
  }
176
190
 
191
+ async getGasPriceEstimation() {
192
+ return getFallbackGasPriceEstimation({ server: this })
193
+ }
194
+
177
195
  async sendRpcRequest(rpcRequest) {
178
196
  const rpcSocket = this.connectRpc()
179
197
  return new Promise((resolve, reject) => {
180
198
  const timeout = setTimeout(() => reject(new Error(RPC_REQUEST_TIMEOUT)), 3000)
181
199
  rpcSocket.emit('request', rpcRequest, (response) => {
182
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
+
183
207
  resolve(response)
184
208
  })
185
209
  })
186
210
  }
187
211
 
212
+ // Transport: WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
188
213
  async sendBatchRequest(batch) {
189
214
  const responses = await this.sendRpcRequest(batch)
190
215
  // FIXME: this falls apart if responses is not an array
@@ -201,12 +226,14 @@ export default class ClarityServer extends EventEmitter {
201
226
  return batch.map((request) => keyed[`${request.id}`])
202
227
  }
203
228
 
229
+ // Transport: Uses sendRpcRequest - WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
204
230
  async sendRequest(request) {
205
231
  const response = await this.sendRpcRequest(request)
206
232
 
207
233
  return this.handleJsonRPCResponse(response)
208
234
  }
209
235
 
236
+ // Transport: Via getCode → sendRequest → WS first → HTTP fallback in ClarityServerV2
210
237
  async isContract(address) {
211
238
  const code = await this.getCode(address)
212
239
  return code.length > 2
@@ -322,11 +349,13 @@ export default class ClarityServer extends EventEmitter {
322
349
  })
323
350
  }
324
351
 
352
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
325
353
  async proxyToCoinNode(params) {
326
354
  const request = this.buildRequest(params)
327
355
  return this.sendRequest(request)
328
356
  }
329
357
 
358
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
330
359
  async balanceOf(address, tokenAddress, tag = 'latest') {
331
360
  const request = this.balanceOfRequest(address, tokenAddress, tag)
332
361
  const result = await this.sendRequest(request)
@@ -338,69 +367,83 @@ export default class ClarityServer extends EventEmitter {
338
367
  }
339
368
  }
340
369
 
370
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
341
371
  async getBalance(...params) {
342
- const request = this.getBalanceRequest(...params)
372
+ const request = this.getBalanceRequest(...params) // eth_getBalance
343
373
  return this.sendRequest(request)
344
374
  }
345
375
 
376
+ // Transport: Via getBalance → sendRequest → WS first → HTTP fallback in ClarityServerV2
346
377
  async getBalanceProxied(...params) {
347
- return this.getBalance(...params)
378
+ return this.getBalance(...params) // eth_getBalance
348
379
  }
349
380
 
381
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
350
382
  async gasPrice(...params) {
351
383
  const request = this.gasPriceRequest(...params)
352
384
  return this.sendRequest(request)
353
385
  }
354
386
 
387
+ // Transport: WS only in ClarityServer, HTTP only in ClarityServerV2 (overridden)
355
388
  async estimateGas(...params) {
356
389
  const request = this.estimateGasRequest(...params)
357
390
  return this.sendRequest(request)
358
391
  }
359
392
 
393
+ // Transport: WS only in ClarityServer, HTTP only in ClarityServerV2 (overridden)
360
394
  async sendRawTransaction(...params) {
361
395
  const request = this.sendRawTransactionRequest(...params)
362
396
  return this.sendRequest(request)
363
397
  }
364
398
 
399
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
365
400
  async getCode(...params) {
366
401
  const request = this.getCodeRequest(...params)
367
402
  return this.sendRequest(request)
368
403
  }
369
404
 
405
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
370
406
  async getStorageAt(...params) {
371
407
  const request = this.getStorageAtRequest(...params)
372
408
  return this.sendRequest(request)
373
409
  }
374
410
 
411
+ // Transport: WS only in ClarityServer, HTTP only in ClarityServerV2 (overridden)
375
412
  async getTransactionCount(...params) {
376
413
  const request = this.getTransactionCountRequest(...params)
377
414
  return this.sendRequest(request)
378
415
  }
379
416
 
417
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
380
418
  async getTransactionByHash(...params) {
381
419
  const request = this.getTransactionByHashRequest(...params)
382
420
  return this.sendRequest(request)
383
421
  }
384
422
 
423
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
385
424
  async getTransactionReceipt(...params) {
386
425
  const request = this.getTransactionReceiptRequest(...params)
387
426
  return this.sendRequest(request)
388
427
  }
389
428
 
429
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
390
430
  async ethCall(...params) {
391
431
  const request = this.ethCallRequest(...params)
392
432
  return this.sendRequest(request)
393
433
  }
394
434
 
435
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
395
436
  async blockNumber(...params) {
396
437
  const request = this.blockNumberRequest(...params)
397
438
  return this.sendRequest(request)
398
439
  }
399
440
 
441
+ // Transport: Via getBlockByNumber → sendRequest → WS first → HTTP fallback in ClarityServerV2
400
442
  async getLatestBlock() {
401
443
  return this.getBlockByNumber('latest')
402
444
  }
403
445
 
446
+ // Transport: Via getLatestBlock → getBlockByNumber → sendRequest → WS first → HTTP fallback in ClarityServerV2
404
447
  async getBaseFeePerGas() {
405
448
  const response = await this.getLatestBlock()
406
449
  if (response.baseFeePerGas) {
@@ -408,46 +451,55 @@ export default class ClarityServer extends EventEmitter {
408
451
  }
409
452
  }
410
453
 
454
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
411
455
  async getBlockByHash(...params) {
412
456
  const request = this.getBlockByHashRequest(...params)
413
457
  return this.sendRequest(request)
414
458
  }
415
459
 
460
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
416
461
  async getBlockTransactionCountByNumber(...params) {
417
462
  const request = this.getBlockTransactionCountByNumberRequest(...params)
418
463
  return this.sendRequest(request)
419
464
  }
420
465
 
466
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
421
467
  async getBlockByNumber(...params) {
422
468
  const request = this.getBlockByNumberRequest(...params)
423
469
  return this.sendRequest(request)
424
470
  }
425
471
 
472
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
426
473
  async simulateV1(...params) {
427
474
  const request = this.simulateV1Request(...params)
428
475
  return this.sendRequest(request)
429
476
  }
430
477
 
478
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
431
479
  async simulateRawTransaction(...params) {
432
480
  const request = this.simulateRawTransactionRequest(...params)
433
481
  return this.sendRequest(request)
434
482
  }
435
483
 
484
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
436
485
  async getCoinbase() {
437
486
  const request = this.coinbaseRequest()
438
487
  return this.sendRequest(request)
439
488
  }
440
489
 
490
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
441
491
  async getCompilers() {
442
492
  const request = this.getCompilersRequest()
443
493
  return this.sendRequest(request)
444
494
  }
445
495
 
496
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
446
497
  async getNetVersion() {
447
498
  const request = this.getNetVersion()
448
499
  return this.sendRequest(request)
449
500
  }
450
501
 
502
+ // Transport: Via sendRequest → WS first → HTTP fallback in ClarityServerV2
451
503
  async getLogs(...params) {
452
504
  const request = this.getLogsRequest(...params)
453
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':
@@ -0,0 +1,31 @@
1
+ import assert from 'minimalistic-assert'
2
+
3
+ const desc = (a, b) => (a > b ? -1 : b > a ? 1 : 0)
4
+
5
+ export const getFallbackGasPriceEstimation = async ({ server }) => {
6
+ const [latestBlock, gasPrice] = await Promise.all([server.getLatestBlock(), server.getGasPrice()])
7
+
8
+ assert(latestBlock, 'expected latestBlock')
9
+ assert(gasPrice, 'expected gasPrice')
10
+
11
+ const baseFeePerGas = latestBlock.baseFeePerGas
12
+ if (!baseFeePerGas) return { gasPrice }
13
+
14
+ const [max, min] = [BigInt(gasPrice), BigInt(baseFeePerGas)].sort(desc)
15
+
16
+ const toHex = (b) => `0x${b.toString(16)}`
17
+
18
+ // TODO: Use `eth_feeHistory` or `eth_maxPriorityFeePerGas`
19
+ // instead (requires allowlist at the RPC).
20
+ // HACK: Infer the RPC's implicit `tipGasPrice`:
21
+ // https://github.com/ethereum/go-ethereum/blob/d3dd48e59db28ea04bd92e4337cdd488ccb8fbec/internal/ethapi/api.go#L69C1-L79C2
22
+ const maxPriorityFeePerGas50Percentile = max - min
23
+
24
+ const rewardPercentiles = {
25
+ 25: toHex(maxPriorityFeePerGas50Percentile / BigInt(2)),
26
+ 50: toHex(maxPriorityFeePerGas50Percentile),
27
+ 75: toHex((maxPriorityFeePerGas50Percentile * BigInt(3)) / BigInt(2)),
28
+ }
29
+
30
+ return { gasPrice, baseFeePerGas, rewardPercentiles }
31
+ }
package/src/fee-utils.js CHANGED
@@ -1,14 +1,5 @@
1
1
  import assert from 'minimalistic-assert'
2
2
 
3
- export const shouldFetchEthLikeFallbackGasPrices = async ({ eip1559Enabled, server }) => {
4
- const [gasPrice, baseFeePerGas] = await Promise.all([
5
- server.getGasPrice(),
6
- eip1559Enabled ? server.getBaseFeePerGas() : undefined,
7
- ])
8
-
9
- return { gasPrice, baseFeePerGas }
10
- }
11
-
12
3
  export const applyMultiplierToPrice = ({ feeAsset, gasPriceMultiplier, price }) => {
13
4
  assert(typeof price === 'string', 'price should be a string')
14
5
  return feeAsset.currency
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,10 +1,7 @@
1
1
  import { FeeMonitor } from '@exodus/asset-lib'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
- import {
5
- calculateEthLikeFeeMonitorUpdate,
6
- shouldFetchEthLikeFallbackGasPrices,
7
- } from './fee-utils.js'
4
+ import { calculateEthLikeFeeMonitorUpdate } from './fee-utils.js'
8
5
 
9
6
  /**
10
7
  * Generic eth server based fee monitor.
@@ -25,11 +22,6 @@ export const serverBasedFeeMonitorFactoryFactory = ({ asset, interval, server, a
25
22
  assert(server, 'server is required')
26
23
  assert(aci, 'aci is required')
27
24
 
28
- const shouldFetchGasPrices = async () => {
29
- const { eip1559Enabled } = await aci.getFeeConfig({ assetName: asset.name })
30
- return shouldFetchEthLikeFallbackGasPrices({ eip1559Enabled, server })
31
- }
32
-
33
25
  const FeeMonitorClass = class ServerBaseEthereumFeeMonitor extends FeeMonitor {
34
26
  constructor({ updateFee }) {
35
27
  assert(updateFee, 'updateFee is required')
@@ -44,7 +36,7 @@ export const serverBasedFeeMonitorFactoryFactory = ({ asset, interval, server, a
44
36
  return calculateEthLikeFeeMonitorUpdate({
45
37
  assetClientInterface: aci,
46
38
  feeAsset: asset,
47
- fetchedGasPrices: await shouldFetchGasPrices(),
39
+ fetchedGasPrices: await server.getGasPriceEstimation(),
48
40
  })
49
41
  }
50
42
  }
@@ -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
+ }