@exodus/ethereum-api 10.2.1 → 10.2.2

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,15 @@
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.2.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.2.1...@exodus/ethereum-api@10.2.2) (2026-10-22)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ethereum-api:** record pending tx in tx log before broadcast ([#9045](https://github.com/ExodusMovement/assets/issues/9045)) ([a38699c](https://github.com/ExodusMovement/assets/commit/a38699c2ed4d8ad0f03b45fa38725ed6020ed685))
12
+
13
+
14
+
6
15
  ## [10.2.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@10.2.0...@exodus/ethereum-api@10.2.1) (2026-10-21)
7
16
 
8
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "10.2.1",
3
+ "version": "10.2.2",
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": "58cc7d254c0b21139ba3ad069fa18998d8ddf458"
68
+ "gitHead": "8b769034df65590a5fcab8a40d0929c162b117c5"
69
69
  }
@@ -16,6 +16,13 @@ import {
16
16
 
17
17
  const { isEmpty, unionBy, zipObject } = lodash
18
18
 
19
+ const getMissingNonce = (tx, txFromNode) => {
20
+ if (Number.isInteger(tx.data?.nonce) || !txFromNode) return
21
+
22
+ const nonce = parseInt(txFromNode.nonce, 16)
23
+ return Number.isSafeInteger(nonce) ? nonce : undefined
24
+ }
25
+
19
26
  // The base ethereum monitor no history class handles listening for assets with no history
20
27
 
21
28
  export class EthereumNoHistoryMonitor extends BaseMonitor {
@@ -132,11 +139,23 @@ export class EthereumNoHistoryMonitor extends BaseMonitor {
132
139
  tx,
133
140
  assetSource: { asset: assetName, walletAccount },
134
141
  })
135
- } else if (isConfirmed) {
136
- txsToUpdate.push({
137
- tx: { ...tx, confirmations: 1 },
138
- assetSource: { asset: assetName, walletAccount },
139
- })
142
+ continue
143
+ }
144
+
145
+ const nonce = getMissingNonce(tx, txFromNode)
146
+ if (!isConfirmed && nonce === undefined) continue
147
+
148
+ txsToUpdate.push({
149
+ tx: {
150
+ ...tx,
151
+ ...(isConfirmed && { confirmations: 1 }),
152
+ ...(nonce !== undefined && { data: { ...tx.data, nonce } }),
153
+ },
154
+ assetSource: { asset: assetName, walletAccount },
155
+ })
156
+
157
+ if (nonce !== undefined) {
158
+ txsToUpdate.push(...(await this.getTokenTxsWithNonce({ tx, nonce, walletAccount })))
140
159
  }
141
160
  }
142
161
 
@@ -146,6 +165,23 @@ export class EthereumNoHistoryMonitor extends BaseMonitor {
146
165
  }
147
166
  }
148
167
 
168
+ async getTokenTxsWithNonce({ tx, nonce, walletAccount }) {
169
+ const tokenTxs = await Promise.all(
170
+ tx.tokens.map(async (assetName) => {
171
+ const tokenTxSet = await this.aci.getTxLog({ assetName, walletAccount })
172
+ const tokenTx = tokenTxSet.get(tx.txId)
173
+ if (!tokenTx || Number.isInteger(tokenTx.data.nonce)) return
174
+
175
+ return {
176
+ tx: { ...tokenTx, data: { ...tokenTx.data, nonce } },
177
+ assetSource: { asset: assetName, walletAccount },
178
+ }
179
+ })
180
+ )
181
+
182
+ return tokenTxs.filter(Boolean)
183
+ }
184
+
149
185
  async tick({ refresh, walletAccount }) {
150
186
  const shouldCheckBlacklist = this.tickCount[walletAccount] === 0
151
187
  const assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
@@ -33,13 +33,23 @@ export const handleBroadcastError = async (
33
33
  return { shouldRetry: false }
34
34
  }
35
35
 
36
+ const throwError = (errorReasonInfo) => {
37
+ throw new EthLikeError({
38
+ message: err.message,
39
+ errorReasonInfo,
40
+ hint,
41
+ traceId: err.traceId,
42
+ baseAssetName: asset.baseAsset.name,
43
+ })
44
+ }
45
+
36
46
  let txAlreadyExists = false
37
47
  if (isAmbiguousError) {
38
48
  try {
39
49
  txAlreadyExists = await transactionExists({ asset, txId })
40
50
  } catch (verifyErr) {
41
- // Can't verify - fall through to original error handling
42
51
  console.warn('Could not verify tx existence:', verifyErr.message)
52
+ throwError(EVM_ERROR_REASONS.broadcastTxFailed)
43
53
  }
44
54
  }
45
55
 
@@ -53,11 +63,5 @@ export const handleBroadcastError = async (
53
63
  // If we "fix" a bump tx by advancing the nonce, we create a brand-new tx instead of replacing the pending one.
54
64
  if (isNonceTooLow && !isHardware && !isBumpTx && !isRetryNonceTx) return { shouldRetry: true }
55
65
 
56
- throw new EthLikeError({
57
- message: err.message,
58
- errorReasonInfo: errorInfo,
59
- hint,
60
- traceId: err.traceId,
61
- baseAssetName: asset.baseAsset.name,
62
- })
66
+ throwError(errorInfo)
63
67
  }
@@ -1,23 +1,66 @@
1
1
  import { parseUnsignedTx, updateNonce } from '@exodus/ethereum-lib'
2
2
  import { safeString } from '@exodus/safe-string'
3
+ import delay from 'delay'
3
4
  import assert from 'minimalistic-assert'
5
+ import ms from 'ms'
4
6
 
7
+ import { EthLikeError, EVM_ERROR_REASONS } from '../error-wrapper.js'
5
8
  import { getOptimisticTxLogEffects } from '../tx-log/index.js'
6
9
  import { signTx } from '../tx-sign/index.js'
7
10
  import { ARBITRARY_ADDRESS } from '../tx-type/index.js'
8
11
  import { handleBroadcastError } from './broadcast-error-handler.js'
9
12
 
13
+ const RESEND_DELAYS = [ms('1s'), ms('3s')]
14
+
15
+ const isOutcomeUnknown = (error) =>
16
+ error instanceof EthLikeError && error.reason === EVM_ERROR_REASONS.broadcastTxFailed.reason
17
+
18
+ const isRejectedByNode = (error) => error instanceof EthLikeError && !isOutcomeUnknown(error)
19
+
20
+ const omitNonce = (optimisticTxLogEffects) =>
21
+ optimisticTxLogEffects.map(({ txs, ...optimisticTxLogEffect }) => ({
22
+ ...optimisticTxLogEffect,
23
+ txs: txs.map(({ data: { nonce: _nonce, ...data }, ...tx }) => ({ ...tx, data })),
24
+ }))
25
+
10
26
  const txSendFactory = ({ assetClientInterface, createTx }) => {
11
27
  assert(assetClientInterface, 'assetClientInterface is required')
12
28
  assert(createTx, 'createTx is required')
13
29
 
14
- return async ({
15
- asset,
16
- walletAccount,
17
- unsignedTx: providedUnsignedTx,
18
- onTxSigned,
19
- ...legacyParams
20
- }) => {
30
+ const writeTxLog = async (optimisticTxLogEffects) => {
31
+ const batch = assetClientInterface.createOperationsBatch()
32
+ for (const optimisticTxLogEffect of optimisticTxLogEffects) {
33
+ assetClientInterface.updateTxLogAndNotifyBatch({ ...optimisticTxLogEffect, batch })
34
+ }
35
+
36
+ await assetClientInterface.executeOperationsBatch(batch)
37
+ }
38
+
39
+ const removeTxLog = async (optimisticTxLogEffects) => {
40
+ const batch = assetClientInterface.createOperationsBatch()
41
+ for (const { assetName, walletAccount, txs } of optimisticTxLogEffects) {
42
+ assetClientInterface.removeTxLogBatch({
43
+ assetName,
44
+ walletAccount,
45
+ txs: txs.map(({ txId }) => ({ txId })),
46
+ batch,
47
+ })
48
+ }
49
+
50
+ await assetClientInterface.executeOperationsBatch(batch)
51
+ }
52
+
53
+ const tryRemoveTxLog = async (optimisticTxLogEffects) => {
54
+ try {
55
+ await removeTxLog(optimisticTxLogEffects)
56
+ return true
57
+ } catch (removeError) {
58
+ console.warn('Could not remove rejected tx from tx log:', removeError.message)
59
+ return false
60
+ }
61
+ }
62
+
63
+ return async ({ asset, walletAccount, unsignedTx: providedUnsignedTx, ...legacyParams }) => {
21
64
  const baseAsset = asset.baseAsset
22
65
 
23
66
  const { unsignedTx } = providedUnsignedTx
@@ -35,99 +78,154 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
35
78
  `The receiving wallet address must not be ${ARBITRARY_ADDRESS}`
36
79
  )
37
80
 
38
- // Are there any signin-level errors that should be caught here?
39
- let { txId, rawTx } = await signTx({
40
- asset,
41
- assetClientInterface,
42
- unsignedTx,
43
- walletAccount,
44
- })
45
- // A caller that must find the tx after a lost broadcast response learns its hash before the network sees it.
46
- await onTxSigned?.({ txId })
81
+ const signAndRecordTx = async () => {
82
+ const { txId, rawTx } = await signTx({
83
+ asset,
84
+ assetClientInterface,
85
+ unsignedTx,
86
+ walletAccount,
87
+ })
47
88
 
48
- try {
49
- await baseAsset.api.broadcastTx(rawTx.toString('hex'))
50
- } catch (err) {
51
- const isBumpTx = !!unsignedTx.txMeta.bumpTxId
52
- const { shouldRetry } = await handleBroadcastError(err, {
89
+ const { nonce, optimisticTxLogEffects } = await getOptimisticTxLogEffects({
53
90
  asset,
91
+ assetClientInterface,
92
+ fromAddress,
54
93
  txId,
55
- isHardware: !!unsignedTx.txMeta.isHardware,
56
- hint: isBumpTx ? safeString`bumpTx` : safeString`broadcastTx`,
57
- isBumpTx,
94
+ unsignedTx,
95
+ walletAccount,
58
96
  })
59
97
 
60
- if (shouldRetry) {
61
- console.debug('trying to send again...') // inject logger factory from platform
62
-
63
- // let's try to fix the nonce issue
64
- let newNonce = await baseAsset.getNonce({
65
- asset,
66
- fromAddress,
67
- walletAccount,
68
- forceFromNode: true,
69
- })
98
+ await writeTxLog(omitNonce(optimisticTxLogEffects))
70
99
 
71
- const triedNonce = parsedTx.nonce
72
- // Defensive: avoid retrying the same nonce if node nonce is stale (non-replacement sends only).
73
- if (!isBumpTx && typeof triedNonce === 'number') {
74
- newNonce = Math.max(newNonce, triedNonce + 1)
75
- }
100
+ return { txId, rawTx, nonce, optimisticTxLogEffects }
101
+ }
76
102
 
77
- // NOTE: An `unsignedTx.txData.transactionBuffer` may be optional
78
- // in `unsignedTx`, since a `providedUnsignedTx` may be
79
- // truthy but composed only of `legacyParams`:
80
- // https://github.com/ExodusMovement/assets/blob/4e3e873e3f5bfa8fce36f60be7d95a3dba9546e4/ethereum/ethereum-lib/src/unsigned-tx/parse-unsigned-tx.js#L104C5-L104C6
81
- //
82
- // TODO: should return an unsignedTx -> i.e. completely replicate the transaction
83
- if (unsignedTx.txData.transactionBuffer) {
84
- // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
85
- unsignedTx.txData.transactionBuffer = updateNonce({
86
- chainId: baseAsset.chainId,
87
- newNonce,
88
- unsignedTx,
89
- })
90
- } else {
91
- // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
92
- unsignedTx.txData.nonce = newNonce
103
+ const resend = async ({ rawTx, txId }) => {
104
+ for (const resendDelay of RESEND_DELAYS) {
105
+ await delay(resendDelay)
106
+ try {
107
+ await baseAsset.api.broadcastTx(rawTx.toString('hex'))
108
+ return true
109
+ } catch (err) {
110
+ try {
111
+ await handleBroadcastError(err, {
112
+ asset,
113
+ txId,
114
+ isHardware: false,
115
+ hint: safeString`resend:broadcastTx`,
116
+ isBumpTx: false,
117
+ isRetryNonceTx: true,
118
+ })
119
+ return true
120
+ } catch (error) {
121
+ if (!isOutcomeUnknown(error)) return false
122
+ }
93
123
  }
124
+ }
94
125
 
95
- ;({ txId, rawTx } = await signTx({
96
- asset,
97
- assetClientInterface,
98
- unsignedTx,
99
- walletAccount,
100
- }))
101
- await onTxSigned?.({ txId })
126
+ return false
127
+ }
102
128
 
129
+ const broadcast = async ({ rawTx, txId, optimisticTxLogEffects, ...handlerOptions }) => {
130
+ try {
131
+ await baseAsset.api.broadcastTx(rawTx.toString('hex'))
132
+ return { shouldRetry: false }
133
+ } catch (err) {
103
134
  try {
104
- await baseAsset.api.broadcastTx(rawTx.toString('hex'))
105
- } catch (retryErr) {
106
- await handleBroadcastError(retryErr, {
135
+ const { shouldRetry } = await handleBroadcastError(err, {
107
136
  asset,
108
137
  txId,
109
- isHardware: false,
110
- hint: safeString`retry:broadcastTx`,
111
- isBumpTx: false, // retry path is only for non-bump anyway
112
- isRetryNonceTx: true,
138
+ ...handlerOptions,
113
139
  })
140
+ return { shouldRetry, broadcastError: err }
141
+ } catch (error) {
142
+ if (isRejectedByNode(error)) {
143
+ await tryRemoveTxLog(optimisticTxLogEffects)
144
+ throw error
145
+ }
146
+
147
+ if (isOutcomeUnknown(error) && (await resend({ rawTx, txId }))) {
148
+ return { shouldRetry: false }
149
+ }
150
+
151
+ throw error
114
152
  }
115
153
  }
116
154
  }
117
155
 
118
- const { nonce, optimisticTxLogEffects } = await getOptimisticTxLogEffects({
119
- asset,
120
- assetClientInterface,
121
- fromAddress,
156
+ // Are there any signin-level errors that should be caught here?
157
+ let { txId, rawTx, nonce, optimisticTxLogEffects } = await signAndRecordTx()
158
+
159
+ const isBumpTx = !!unsignedTx.txMeta.bumpTxId
160
+ const { shouldRetry, broadcastError } = await broadcast({
161
+ rawTx,
122
162
  txId,
123
- unsignedTx,
124
- walletAccount,
163
+ optimisticTxLogEffects,
164
+ isHardware: !!unsignedTx.txMeta.isHardware,
165
+ hint: isBumpTx ? safeString`bumpTx` : safeString`broadcastTx`,
166
+ isBumpTx,
125
167
  })
126
168
 
127
- for (const optimisticTxLogEffect of optimisticTxLogEffects) {
128
- await assetClientInterface.updateTxLogAndNotify(optimisticTxLogEffect)
169
+ if (shouldRetry) {
170
+ console.debug('trying to send again...') // inject logger factory from platform
171
+
172
+ if (!(await tryRemoveTxLog(optimisticTxLogEffects))) {
173
+ throw new EthLikeError({
174
+ message: broadcastError.message,
175
+ errorReasonInfo: EVM_ERROR_REASONS.nonceTooLow,
176
+ hint: safeString`broadcastTx`,
177
+ traceId: broadcastError.traceId,
178
+ baseAssetName: baseAsset.name,
179
+ })
180
+ }
181
+
182
+ // let's try to fix the nonce issue
183
+ let newNonce = await baseAsset.getNonce({
184
+ asset,
185
+ fromAddress,
186
+ walletAccount,
187
+ forceFromNode: true,
188
+ })
189
+
190
+ const triedNonce = parsedTx.nonce
191
+ // Defensive: avoid retrying the same nonce if node nonce is stale (non-replacement sends only).
192
+ if (!isBumpTx && typeof triedNonce === 'number') {
193
+ newNonce = Math.max(newNonce, triedNonce + 1)
194
+ }
195
+
196
+ // NOTE: An `unsignedTx.txData.transactionBuffer` may be optional
197
+ // in `unsignedTx`, since a `providedUnsignedTx` may be
198
+ // truthy but composed only of `legacyParams`:
199
+ // https://github.com/ExodusMovement/assets/blob/4e3e873e3f5bfa8fce36f60be7d95a3dba9546e4/ethereum/ethereum-lib/src/unsigned-tx/parse-unsigned-tx.js#L104C5-L104C6
200
+ //
201
+ // TODO: should return an unsignedTx -> i.e. completely replicate the transaction
202
+ if (unsignedTx.txData.transactionBuffer) {
203
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
204
+ unsignedTx.txData.transactionBuffer = updateNonce({
205
+ chainId: baseAsset.chainId,
206
+ newNonce,
207
+ unsignedTx,
208
+ })
209
+ } else {
210
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
211
+ unsignedTx.txData.nonce = newNonce
212
+ }
213
+
214
+ ;({ txId, rawTx, nonce, optimisticTxLogEffects } = await signAndRecordTx())
215
+
216
+ await broadcast({
217
+ rawTx,
218
+ txId,
219
+ optimisticTxLogEffects,
220
+ isHardware: false,
221
+ hint: safeString`retry:broadcastTx`,
222
+ isBumpTx: false, // retry path is only for non-bump anyway
223
+ isRetryNonceTx: true,
224
+ })
129
225
  }
130
226
 
227
+ await writeTxLog(optimisticTxLogEffects)
228
+
131
229
  return { txId, nonce }
132
230
  }
133
231
  }