@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.
|
|
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": "
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
57
|
-
message: err.message,
|
|
58
|
-
errorReasonInfo: errorInfo,
|
|
59
|
-
hint,
|
|
60
|
-
traceId: err.traceId,
|
|
61
|
-
baseAssetName: asset.baseAsset.name,
|
|
62
|
-
})
|
|
66
|
+
throwError(errorInfo)
|
|
63
67
|
}
|
package/src/tx-send/tx-send.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
isBumpTx,
|
|
94
|
+
unsignedTx,
|
|
95
|
+
walletAccount,
|
|
58
96
|
})
|
|
59
97
|
|
|
60
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
if (!isBumpTx && typeof triedNonce === 'number') {
|
|
74
|
-
newNonce = Math.max(newNonce, triedNonce + 1)
|
|
75
|
-
}
|
|
100
|
+
return { txId, rawTx, nonce, optimisticTxLogEffects }
|
|
101
|
+
}
|
|
76
102
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
105
|
-
} catch (retryErr) {
|
|
106
|
-
await handleBroadcastError(retryErr, {
|
|
135
|
+
const { shouldRetry } = await handleBroadcastError(err, {
|
|
107
136
|
asset,
|
|
108
137
|
txId,
|
|
109
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
163
|
+
optimisticTxLogEffects,
|
|
164
|
+
isHardware: !!unsignedTx.txMeta.isHardware,
|
|
165
|
+
hint: isBumpTx ? safeString`bumpTx` : safeString`broadcastTx`,
|
|
166
|
+
isBumpTx,
|
|
125
167
|
})
|
|
126
168
|
|
|
127
|
-
|
|
128
|
-
|
|
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
|
}
|