@exodus/ethereum-api 10.3.3 → 10.4.0

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,24 @@
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
+ ## [10.4.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.3.4...@exodus/ethereum-api@10.4.0) (2026-11-14)
7
+
8
+
9
+ ### Features
10
+
11
+ * **ethereum-api:** add EXOD send error codes and user hints ([#9205](https://github.com/ExodusMovement/assets/issues/9205)) ([ebdbd9e](https://github.com/ExodusMovement/assets/commit/ebdbd9e778107182c3e0b7d0fd12866f30d384e2))
12
+
13
+
14
+
15
+ ## [10.3.4](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.3.3...@exodus/ethereum-api@10.3.4) (2026-11-06)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **ethereum-api:** open the gas-price socket only while a wallet account syncs the network ([#9223](https://github.com/ExodusMovement/assets/issues/9223)) ([3564b70](https://github.com/ExodusMovement/assets/commit/3564b707b543596dd9e484da4a882585f9a311b3))
21
+
22
+
23
+
6
24
  ## [10.3.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.3.2...@exodus/ethereum-api@10.3.3) (2026-11-04)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "10.3.3",
3
+ "version": "10.4.0",
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",
@@ -65,5 +65,5 @@
65
65
  "type": "git",
66
66
  "url": "git+https://github.com/ExodusMovement/assets.git"
67
67
  },
68
- "gitHead": "a219ccf5bd243722ab92020f7f61fa77eced5250"
68
+ "gitHead": "e646d8655bc613c1f188e7d7187498d82700ae90"
69
69
  }
@@ -58,6 +58,10 @@ export const EVM_ERROR_REASONS = {
58
58
  reason: safeString`broadcast tx failed`,
59
59
  type: EVM_ERROR_TYPES.BROADCAST,
60
60
  },
61
+ broadcastNodeUnavailable: {
62
+ reason: safeString`broadcast node unavailable`,
63
+ type: EVM_ERROR_TYPES.BROADCAST,
64
+ },
61
65
 
62
66
  // Reversion reasons
63
67
  executionReverted: {
@@ -303,9 +307,10 @@ export class EthLikeError extends Error {
303
307
  * @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.
304
308
  * @param {TraceId} [traceId] - Optional TraceId object from HTTP response 'traceparent' header for debugging.
305
309
  * @param {string} [baseAssetName] - Optional base asset name (e.g. 'ethereum', 'bnbsmartchain') for identifying the network.
310
+ * @param {Error} [cause] - Optional original error, kept so its name and code stay readable.
306
311
  */
307
- constructor({ message, errorReasonInfo, hint, traceId, baseAssetName }) {
308
- super(message)
312
+ constructor({ message, errorReasonInfo, hint, traceId, baseAssetName, cause }) {
313
+ super(message, cause === undefined ? undefined : { cause })
309
314
  this.name = safeString`EthLikeError`
310
315
  const filteredHint = this.#extractHint(hint)
311
316
  // Only filtered hints are kept on the stack so that subsequent joins via
@@ -335,6 +340,7 @@ export class EthLikeError extends Error {
335
340
  type: this.type,
336
341
  traceId: this.traceId,
337
342
  baseAssetName: this.baseAssetName,
343
+ sourceHint: this.sourceHint,
338
344
  }
339
345
  }
340
346
 
@@ -386,6 +392,7 @@ export const withErrorReason = async ({ promise, errorReasonInfo, hint, baseAsse
386
392
  hint,
387
393
  traceId: err.traceId,
388
394
  baseAssetName,
395
+ cause: err,
389
396
  })
390
397
  }
391
398
  }
package/src/errors.js ADDED
@@ -0,0 +1,250 @@
1
+ import { safeString } from '@exodus/safe-string'
2
+
3
+ import { EVM_ERROR_REASONS, EVM_ERROR_TYPES, getEvmErrorReason } from './error-wrapper.js'
4
+
5
+ const freezeTable = (table) => {
6
+ for (const entry of Object.values(table)) {
7
+ for (const value of Object.values(entry)) {
8
+ if (Array.isArray(value)) Object.freeze(value)
9
+ }
10
+
11
+ Object.freeze(entry)
12
+ }
13
+
14
+ return Object.freeze(table)
15
+ }
16
+
17
+ export const EVM_ERRORS = freezeTable({
18
+ INSUFFICIENT_FUNDS_FOR_FEE: {
19
+ code: 'EXOD-3000',
20
+ hint: safeString`Not enough funds to pay the network fee`,
21
+ action: 'add-funds',
22
+ reasons: [EVM_ERROR_REASONS.insufficientFundsForGas.reason],
23
+ },
24
+ INSUFFICIENT_BALANCE: {
25
+ code: 'EXOD-3001',
26
+ hint: safeString`Not enough balance to send this amount`,
27
+ action: 'add-funds',
28
+ reasons: [EVM_ERROR_REASONS.insufficientBalance.reason],
29
+ },
30
+ FEE_EXCEEDS_AMOUNT: {
31
+ code: 'EXOD-3002',
32
+ hint: safeString`The network fee is higher than the amount to send`,
33
+ action: 'edit-transaction',
34
+ names: ['AmountIncludesFeeUnderflowError'],
35
+ },
36
+ FEE_TOO_LOW: {
37
+ code: 'EXOD-3003',
38
+ hint: safeString`The network fee is too low, try again with a higher fee`,
39
+ action: 'edit-transaction',
40
+ reasons: [
41
+ EVM_ERROR_REASONS.gasPriceBelowMin.reason,
42
+ EVM_ERROR_REASONS.transactionUnderpriced.reason,
43
+ ],
44
+ },
45
+ REPLACEMENT_FEE_TOO_LOW: {
46
+ code: 'EXOD-3004',
47
+ hint: safeString`The fee is too low to replace the pending transaction`,
48
+ action: 'edit-transaction',
49
+ reasons: [EVM_ERROR_REASONS.replacementTransactionUnderpriced.reason],
50
+ },
51
+ GAS_LIMIT_TOO_LOW: {
52
+ code: 'EXOD-3005',
53
+ hint: safeString`The gas limit is too low for this transaction`,
54
+ action: 'edit-transaction',
55
+ reasons: [EVM_ERROR_REASONS.insufficientGas.reason],
56
+ },
57
+
58
+ INVALID_RECIPIENT: {
59
+ code: 'EXOD-3020',
60
+ hint: safeString`This recipient address is not allowed`,
61
+ action: 'edit-transaction',
62
+ patterns: [/^The receiving wallet address must not be\b/],
63
+ },
64
+
65
+ NONCE_TOO_LOW: {
66
+ code: 'EXOD-3040',
67
+ hint: safeString`This transaction conflicts with an earlier one, please try again`,
68
+ action: 'refresh-and-retry',
69
+ reasons: [EVM_ERROR_REASONS.nonceTooLow.reason],
70
+ },
71
+ NONCE_TOO_HIGH: {
72
+ code: 'EXOD-3041',
73
+ hint: safeString`An earlier transaction is still pending, try again once it confirms`,
74
+ action: 'retry-later',
75
+ reasons: [EVM_ERROR_REASONS.nonceTooHigh.reason],
76
+ },
77
+ NONCE_GAP: {
78
+ code: 'EXOD-3042',
79
+ hint: safeString`An earlier transaction was dropped, send a new transaction first`,
80
+ action: 'refresh-and-retry',
81
+ patterns: [/ERR_BUMP_TX_NONCE_GAP/],
82
+ reasons: [EVM_ERROR_REASONS.futureTransactionReplace.reason],
83
+ },
84
+ BUMP_TX_UNAVAILABLE: {
85
+ code: 'EXOD-3043',
86
+ hint: safeString`This transaction can no longer be sped up`,
87
+ action: 'edit-transaction',
88
+ patterns: [/ERR_BUMP_TX_DROPPED|^Cannot bump transaction\b/],
89
+ },
90
+ INVALID_SIGNATURE: {
91
+ code: 'EXOD-3044',
92
+ hint: safeString`The network rejected the transaction signature`,
93
+ action: 'contact-support',
94
+ reasons: [EVM_ERROR_REASONS.invalidRlpSerialization.reason],
95
+ },
96
+ REPLAY_PROTECTION_REQUIRED: {
97
+ code: 'EXOD-3045',
98
+ hint: safeString`This network requires replay-protected transactions`,
99
+ action: 'contact-support',
100
+ reasons: [EVM_ERROR_REASONS.eip155Required.reason],
101
+ },
102
+ TX_TYPE_NOT_SUPPORTED: {
103
+ code: 'EXOD-3046',
104
+ hint: safeString`This network does not support this transaction type`,
105
+ action: 'contact-support',
106
+ reasons: [EVM_ERROR_REASONS.transactionTypeNotSupported.reason],
107
+ },
108
+ CONTRACT_REVERTED: {
109
+ code: 'EXOD-3047',
110
+ hint: safeString`The contract rejected this transaction`,
111
+ action: 'edit-transaction',
112
+ reasons: [
113
+ EVM_ERROR_REASONS.executionReverted.reason,
114
+ EVM_ERROR_REASONS.invalidOpcode.reason,
115
+ EVM_ERROR_REASONS.invalidJumpDestination.reason,
116
+ EVM_ERROR_REASONS.mathError.reason,
117
+ ],
118
+ },
119
+ ZERO_AMOUNT: {
120
+ code: 'EXOD-3048',
121
+ hint: safeString`The amount must be greater than zero`,
122
+ action: 'edit-transaction',
123
+ reasons: [EVM_ERROR_REASONS.zeroTransfer.reason],
124
+ },
125
+ GAS_ESTIMATION_FAILED: {
126
+ code: 'EXOD-3049',
127
+ hint: safeString`Could not estimate the network fee for this transaction`,
128
+ action: 'retry',
129
+ reasons: [EVM_ERROR_REASONS.fetchGasLimitFailed.reason],
130
+ },
131
+
132
+ RATE_LIMITED: {
133
+ code: 'EXOD-3061',
134
+ hint: safeString`The network is busy, try again later`,
135
+ action: 'retry-later',
136
+ patterns: [/\breturned 429\b|too many requests|rate.?limit/i],
137
+ },
138
+ NODE_REQUEST_FAILED: {
139
+ code: 'EXOD-3062',
140
+ hint: safeString`The network node returned an error, try again later`,
141
+ action: 'retry',
142
+ reasons: [EVM_ERROR_REASONS.broadcastNodeUnavailable.reason],
143
+ types: [EVM_ERROR_TYPES.NODE_STATE_READ, EVM_ERROR_TYPES.CONTRACT_CALL],
144
+ patterns: [/\breturned 50[03]\b/],
145
+ prefixes: ['Bad rpc response'],
146
+ },
147
+ NETWORK_UNREACHABLE: {
148
+ code: 'EXOD-3060',
149
+ hint: safeString`Could not reach the network, check your connection and try again`,
150
+ action: 'retry',
151
+ names: ['AbortError', 'TimeoutError'],
152
+ patterns: [
153
+ /failed to fetch|fetch failed|network ?request failed|networkerror|socket hang up|\be(?:conn(?:refused|reset|aborted)|timedout|notfound|ai_again|hostunreach|netunreach)\b|\btimeout\b|rpc_request_timeout|timed out|\breturned 50[24]\b/i,
154
+ ],
155
+ },
156
+ BROADCAST_OUTCOME_UNKNOWN: {
157
+ code: 'EXOD-3063',
158
+ hint: safeString`The transaction may not have been sent, check your history before retrying`,
159
+ action: 'check-history',
160
+ reasons: [EVM_ERROR_REASONS.broadcastTxFailed.reason],
161
+ },
162
+ TX_RECORD_FAILED: {
163
+ code: 'EXOD-3064',
164
+ hint: safeString`The transaction was sent but could not be saved, check your history before sending again`,
165
+ action: 'check-history',
166
+ },
167
+
168
+ DELEGATED_ACCOUNT: {
169
+ code: 'EXOD-3080',
170
+ hint: safeString`This account's delegation does not allow this transaction`,
171
+ action: 'contact-support',
172
+ reasons: [EVM_ERROR_REASONS.delegatedAccountError.reason],
173
+ },
174
+
175
+ UNKNOWN_SEND_FAILURE: {
176
+ code: 'EXOD-3099',
177
+ hint: safeString`The transaction failed, please try again`,
178
+ action: 'refresh-and-retry',
179
+ },
180
+ })
181
+
182
+ const ENTRIES = Object.entries(EVM_ERRORS)
183
+
184
+ const EVM_CODES = new Set(ENTRIES.map(([, { code }]) => code))
185
+
186
+ const findKey = (field, matches) => ENTRIES.find(([, entry]) => entry[field]?.some(matches))?.[0]
187
+
188
+ const REASON_TO_ERROR_KEY = new Map(
189
+ ENTRIES.flatMap(([key, { reasons = [] }]) => reasons.map((reason) => [reason, key]))
190
+ )
191
+
192
+ const MAX_MESSAGE_LENGTH = 1000
193
+
194
+ const getErrorKey = (error) => {
195
+ const nameKey = findKey('names', (name) => name === error.name || name === error.cause?.name)
196
+ if (nameKey) return nameKey
197
+ if (error.reason === EVM_ERROR_REASONS.broadcastTxFailed.reason) {
198
+ return 'BROADCAST_OUTCOME_UNKNOWN'
199
+ }
200
+
201
+ const message =
202
+ typeof error.message === 'string' ? error.message.slice(0, MAX_MESSAGE_LENGTH) : ''
203
+
204
+ const messageReasonKey = REASON_TO_ERROR_KEY.get(getEvmErrorReason(message)?.reason)
205
+ if (messageReasonKey) return messageReasonKey
206
+
207
+ const patternKey = findKey('patterns', (pattern) => pattern.test(message))
208
+ if (patternKey) return patternKey
209
+
210
+ const reasonKey = REASON_TO_ERROR_KEY.get(error.reason)
211
+ if (reasonKey) return reasonKey
212
+
213
+ const nodeKey =
214
+ findKey('types', (type) => type === error.type) ??
215
+ findKey('prefixes', (prefix) => message.startsWith(prefix))
216
+ if (nodeKey) return nodeKey
217
+
218
+ return 'UNKNOWN_SEND_FAILURE'
219
+ }
220
+
221
+ const defineField = (error, field, value) =>
222
+ Object.defineProperty(error, field, {
223
+ value,
224
+ writable: true,
225
+ enumerable: true,
226
+ configurable: true,
227
+ })
228
+
229
+ const setErrorCode = (error, key) => {
230
+ const { code, hint, action } = EVM_ERRORS[key]
231
+ const sourceCode = error.code ?? error.cause?.code
232
+ if (sourceCode !== undefined) defineField(error, 'sourceCode', sourceCode)
233
+ if (error.hint !== undefined) defineField(error, 'sourceHint', error.hint)
234
+ defineField(error, 'code', code)
235
+ defineField(error, 'hint', hint)
236
+ return defineField(error, 'recommendedAction', action)
237
+ }
238
+
239
+ export const withTxRecordErrorCode = (error) => {
240
+ if (!error || typeof error !== 'object') return error
241
+
242
+ return setErrorCode(error, 'TX_RECORD_FAILED')
243
+ }
244
+
245
+ export const withSendErrorCode = (error) => {
246
+ if (!error || typeof error !== 'object') return error
247
+ if (EVM_CODES.has(error.code)) return error
248
+
249
+ return setErrorCode(error, getErrorKey(error))
250
+ }
@@ -56,6 +56,10 @@ export default class ApiCoinNodesServer extends EthLikeServer {
56
56
  revisedError.traceId = traceId
57
57
  }
58
58
 
59
+ if (response?.__errorChain) {
60
+ revisedError.errorChain = response.__errorChain
61
+ }
62
+
59
63
  throw revisedError
60
64
  }
61
65
 
@@ -114,6 +114,10 @@ export default class ClarityServer extends EthLikeServer {
114
114
  revisedError.traceId = traceId
115
115
  }
116
116
 
117
+ if (response?.__errorChain) {
118
+ revisedError.errorChain = response.__errorChain
119
+ }
120
+
117
121
  throw revisedError
118
122
  }
119
123
 
@@ -41,8 +41,22 @@ export const fetchJson = async (url, fetchOptions) => {
41
41
  return json
42
42
  }
43
43
 
44
- export const fetchJsonRetry = (url, fetchOptions) => {
44
+ export const fetchJsonRetry = async (url, fetchOptions) => {
45
45
  const waitTimes = ['3s']
46
- const fetchWithRetry = retry(fetchJson, { delayTimesMs: waitTimes })
47
- return fetchWithRetry(url, fetchOptions)
46
+ const errorChain = []
47
+ const fetchAttempt = async () => {
48
+ try {
49
+ return await fetchJson(url, fetchOptions)
50
+ } catch (err) {
51
+ errorChain.push(err)
52
+ throw err
53
+ }
54
+ }
55
+
56
+ const json = await retry(fetchAttempt, { delayTimesMs: waitTimes })()
57
+ if (errorChain.length > 0 && json?.error) {
58
+ json.__errorChain = errorChain
59
+ }
60
+
61
+ return json
48
62
  }
package/src/index.js CHANGED
@@ -120,6 +120,8 @@ export {
120
120
  EthLikeError,
121
121
  } from './error-wrapper.js'
122
122
 
123
+ export { EVM_ERRORS } from './errors.js'
124
+
123
125
  export { txSendFactory } from './tx-send/index.js'
124
126
 
125
127
  export { createAssetFactory } from './create-asset.js'
@@ -30,7 +30,7 @@ const fetch = async (path, config = Object.create(null)) => {
30
30
  const isFiniteInteger = (e) => Number.isInteger(e) && Number.isFinite(e)
31
31
 
32
32
  // Simulate an unstake to predict how much ETH the interchange pool returns instantly.
33
- // https://wallet-sdk-api.everstake.one/swagger/#/Ethereum/post_ethereum_pool_simulate_unstake
33
+ // https://wallet-sdk-api.everstake.com/swagger/#/Ethereum/post_ethereum_pool_simulate_unstake
34
34
  // Routed through the Exodus Everstake proxy rather than calling Everstake directly from the client.
35
35
  //
36
36
  // Inputs:
@@ -87,7 +87,7 @@ export const getCachedEverstakeValidatorsQueue = createCachedStakingRead({
87
87
  })
88
88
 
89
89
  export const getEverstakeValidatorsQueue = async () => {
90
- // https://swagger.eth-api-b2c.everstake.one/#/Staking/validatorsQueue
90
+ // https://swagger.eth-api-b2c.everstake.com/#/Staking/validatorsQueue
91
91
  const result = await fetch('v1/validators/queue')
92
92
 
93
93
  const {
package/src/tx-create.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  import createEthereumJsTx from '@exodus/ethereum-lib/src/unsigned-tx/create-ethereumjs-tx.js'
7
7
  import assert from 'minimalistic-assert'
8
8
 
9
+ import { withSendErrorCode } from './errors.js'
9
10
  import { authenticateReplacement } from './transaction-authentication.js'
10
11
  import { ensureSaneEip1559GasPriceForTipGasPrice } from './fee-utils.js'
11
12
  import { fetchGasLimit } from './gas-estimation.js'
@@ -432,7 +433,7 @@ const createBumpUnsignedTx = async ({
432
433
  export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonce }) => {
433
434
  assert(assetClientInterface, 'assetClientInterface is required')
434
435
  assert(typeof chainId === 'number', 'chainId is required')
435
- return async ({
436
+ const createTx = async ({
436
437
  asset,
437
438
  walletAccount,
438
439
  nft, // when sending nfts
@@ -652,4 +653,12 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
652
653
  eip1559Enabled,
653
654
  })
654
655
  }
656
+
657
+ return async (params) => {
658
+ try {
659
+ return await createTx(params)
660
+ } catch (err) {
661
+ throw withSendErrorCode(err)
662
+ }
663
+ }
655
664
  }
@@ -27,6 +27,8 @@ import {
27
27
  const { isEmpty } = lodash
28
28
 
29
29
  export class ClarityMonitor extends AuthenticatedEthereumMonitor {
30
+ #hasWalletAccounts = false
31
+
30
32
  constructor({
31
33
  server,
32
34
  config,
@@ -49,6 +51,9 @@ export class ClarityMonitor extends AuthenticatedEthereumMonitor {
49
51
  getTxLog: (...args) => this.aci.getTxLog(...args),
50
52
  })
51
53
  this.addHook('before-start', (...args) => this.beforeStart(...args))
54
+ this.addHook('before-tick-multiple-wallet-accounts', ({ walletAccounts }) =>
55
+ this.#updateFeeSocket(walletAccounts)
56
+ )
52
57
  this.addHook('after-stop', (...args) => this.afterStop(...args))
53
58
  }
54
59
 
@@ -67,7 +72,7 @@ export class ClarityMonitor extends AuthenticatedEthereumMonitor {
67
72
 
68
73
  this.server.setURI(uri)
69
74
  this.subscribeWalletAddresses()
70
- if (this.config.GAS_PRICE_FROM_WEBSOCKET) {
75
+ if (this.config.GAS_PRICE_FROM_WEBSOCKET && this.#hasWalletAccounts) {
71
76
  this.server.connectFee()
72
77
  }
73
78
  }
@@ -508,15 +513,13 @@ export class ClarityMonitor extends AuthenticatedEthereumMonitor {
508
513
 
509
514
  async beforeStart() {
510
515
  this.listenToServerEvents()
511
- if (this.config.GAS_PRICE_FROM_WEBSOCKET) {
512
- this.server.connectFee()
513
- }
514
-
516
+ // The fee socket opens on the first tick that finds a wallet account; see #updateFeeSocket.
515
517
  return this.subscribeWalletAddresses()
516
518
  }
517
519
 
518
520
  async afterStop() {
519
521
  this.server.dispose()
522
+ this.#hasWalletAccounts = false
520
523
  }
521
524
 
522
525
  isTipSync({ derivedData, refresh }) {
@@ -534,4 +537,20 @@ export class ClarityMonitor extends AuthenticatedEthereumMonitor {
534
537
  this.server.on('transaction', (...args) => this.onTransaction(...args))
535
538
  this.server.on('feeUpdated', (...args) => this.onFeeUpdated(...args))
536
539
  }
540
+
541
+ // A monitor can run with no wallet account on its network, such as Polygon before Predictions
542
+ // creates its account, so the gas-price socket stays closed until a tick finds one.
543
+ #updateFeeSocket(walletAccounts) {
544
+ if (!this.config.GAS_PRICE_FROM_WEBSOCKET) return
545
+
546
+ const hasWalletAccounts = walletAccounts.length > 0
547
+ if (hasWalletAccounts === this.#hasWalletAccounts) return
548
+
549
+ this.#hasWalletAccounts = hasWalletAccounts
550
+ if (hasWalletAccounts) {
551
+ this.server.connectFee()
552
+ } else {
553
+ this.server.disconnectFee()
554
+ }
555
+ }
537
556
  }
@@ -1,6 +1,25 @@
1
1
  import { EthLikeError, EVM_ERROR_REASONS, getEvmErrorReason } from '../error-wrapper.js'
2
2
  import { transactionExists } from '../eth-like-util.js'
3
3
 
4
+ const NODE_UNAVAILABLE_MESSAGE = /\breturned (?:429|50[03])\b/
5
+
6
+ const isNodeUnavailable = (attempt) => NODE_UNAVAILABLE_MESSAGE.test(attempt?.message ?? '')
7
+
8
+ const isOutcomeUnknownAttempt = (attempt) =>
9
+ !isNodeUnavailable(attempt) && !getEvmErrorReason(attempt?.message ?? '')
10
+
11
+ const getReplyReason = (err) =>
12
+ getEvmErrorReason(err.message) ??
13
+ (isNodeUnavailable(err)
14
+ ? EVM_ERROR_REASONS.broadcastNodeUnavailable
15
+ : EVM_ERROR_REASONS.broadcastTxFailed)
16
+
17
+ const getAttemptReason = (err, replyInfo) => {
18
+ if (replyInfo.reason === EVM_ERROR_REASONS.alreadyKnown.reason) return replyInfo
19
+ if (!err.errorChain?.some(isOutcomeUnknownAttempt)) return replyInfo
20
+ return EVM_ERROR_REASONS.broadcastTxFailed
21
+ }
22
+
4
23
  /**
5
24
  * Handles broadcast errors by parsing the error message and either throwing
6
25
  * an appropriate EthLikeError or returning info for retry logic.
@@ -20,13 +39,13 @@ export const handleBroadcastError = async (
20
39
  err,
21
40
  { asset, txId, isHardware, hint, isBumpTx, isRetryNonceTx }
22
41
  ) => {
23
- const message = err.message
24
-
25
- const errorInfo = getEvmErrorReason(message) || EVM_ERROR_REASONS.broadcastTxFailed
42
+ const replyInfo = getReplyReason(err)
43
+ const errorInfo = getAttemptReason(err, replyInfo)
26
44
 
27
45
  const isNonceTooLow = errorInfo.reason === EVM_ERROR_REASONS.nonceTooLow.reason
28
46
  const isAmbiguousError =
29
- isNonceTooLow || errorInfo.reason === EVM_ERROR_REASONS.transactionUnderpriced.reason
47
+ replyInfo.reason === EVM_ERROR_REASONS.nonceTooLow.reason ||
48
+ replyInfo.reason === EVM_ERROR_REASONS.transactionUnderpriced.reason
30
49
 
31
50
  if (errorInfo.reason === EVM_ERROR_REASONS.alreadyKnown.reason) {
32
51
  console.debug('tx already broadcast')
@@ -5,6 +5,7 @@ import assert from 'minimalistic-assert'
5
5
  import ms from 'ms'
6
6
 
7
7
  import { EthLikeError, EVM_ERROR_REASONS } from '../error-wrapper.js'
8
+ import { withSendErrorCode, withTxRecordErrorCode } from '../errors.js'
8
9
  import { getOptimisticTxLogEffects } from '../tx-log/index.js'
9
10
  import { signTx } from '../tx-sign/index.js'
10
11
  import { ARBITRARY_ADDRESS } from '../tx-type/index.js'
@@ -17,6 +18,10 @@ const isOutcomeUnknown = (error) =>
17
18
 
18
19
  const isRejectedByNode = (error) => error instanceof EthLikeError && !isOutcomeUnknown(error)
19
20
 
21
+ const isNodeUnavailable = (error) =>
22
+ error instanceof EthLikeError &&
23
+ error.reason === EVM_ERROR_REASONS.broadcastNodeUnavailable.reason
24
+
20
25
  const omitNonce = (optimisticTxLogEffects) =>
21
26
  optimisticTxLogEffects.map(({ txs, ...optimisticTxLogEffect }) => ({
22
27
  ...optimisticTxLogEffect,
@@ -60,7 +65,12 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
60
65
  }
61
66
  }
62
67
 
63
- return async ({ asset, walletAccount, unsignedTx: providedUnsignedTx, ...legacyParams }) => {
68
+ const sendTx = async ({
69
+ asset,
70
+ walletAccount,
71
+ unsignedTx: providedUnsignedTx,
72
+ ...legacyParams
73
+ }) => {
64
74
  const baseAsset = asset.baseAsset
65
75
 
66
76
  const { unsignedTx } = providedUnsignedTx
@@ -118,7 +128,7 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
118
128
  })
119
129
  return true
120
130
  } catch (error) {
121
- if (!isOutcomeUnknown(error)) return false
131
+ if (!isOutcomeUnknown(error) && !isNodeUnavailable(error)) return false
122
132
  }
123
133
  }
124
134
  }
@@ -224,10 +234,22 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
224
234
  })
225
235
  }
226
236
 
227
- await writeTxLog(optimisticTxLogEffects)
237
+ try {
238
+ await writeTxLog(optimisticTxLogEffects)
239
+ } catch (err) {
240
+ throw withTxRecordErrorCode(err)
241
+ }
228
242
 
229
243
  return { txId, nonce }
230
244
  }
245
+
246
+ return async (params) => {
247
+ try {
248
+ return await sendTx(params)
249
+ } catch (err) {
250
+ throw withSendErrorCode(err)
251
+ }
252
+ }
231
253
  }
232
254
 
233
255
  export default txSendFactory