@exodus/bitcoin-api 4.1.3 → 4.1.5

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,22 @@
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
+ ## [4.1.5](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.1.4...@exodus/bitcoin-api@4.1.5) (2025-10-16)
7
+
8
+ **Note:** Version bump only for package @exodus/bitcoin-api
9
+
10
+
11
+
12
+
13
+
14
+ ## [4.1.4](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.1.3...@exodus/bitcoin-api@4.1.4) (2025-10-15)
15
+
16
+ **Note:** Version bump only for package @exodus/bitcoin-api
17
+
18
+
19
+
20
+
21
+
6
22
  ## [4.1.3](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.1.1...@exodus/bitcoin-api@4.1.3) (2025-10-14)
7
23
 
8
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.1.3",
3
+ "version": "4.1.5",
4
4
  "description": "Bitcoin transaction and fee monitors, RPC with the blockchain node, other networking code.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -60,5 +60,5 @@
60
60
  "type": "git",
61
61
  "url": "git+https://github.com/ExodusMovement/assets.git"
62
62
  },
63
- "gitHead": "48e0d08e2fe044fb98d2fb16fc790f642a3c1eb2"
63
+ "gitHead": "d6f680d217f82d3b165372a7bc728899b719a398"
64
64
  }
@@ -10,6 +10,255 @@ import { getUnconfirmedTxAncestorMap } from '../unconfirmed-ancestor-data.js'
10
10
  import { getUsableUtxos, getUtxos } from '../utxos-utils.js'
11
11
  import { createInputs, createOutput, getBlockHeight, getNonWitnessTxs } from './tx-create-utils.js'
12
12
 
13
+ // Helper to shuffle arrays for randomized input/output ordering
14
+ const shuffle = (list) => {
15
+ return lodash.shuffle(list)
16
+ }
17
+
18
+ function validateTransactionParams({ assetClientInterface, asset, toAddress, bumpTxId }) {
19
+ assert(assetClientInterface, `assetClientInterface must be supplied in sendTx for ${asset.name}`)
20
+ assert(
21
+ toAddress || bumpTxId,
22
+ 'should not be called without either a receiving toAddress or to bump a tx'
23
+ )
24
+ }
25
+
26
+ async function prepareTransactionContext({
27
+ asset,
28
+ assetName,
29
+ walletAccount,
30
+ multipleAddressesEnabled,
31
+ assetClientInterface,
32
+ accountState,
33
+ insightClient,
34
+ feeData,
35
+ feePerKB,
36
+ isExchange,
37
+ isRbfAllowed,
38
+ }) {
39
+ const updatedFeeData = { ...feeData, feePerKB: feePerKB ?? feeData.feePerKB }
40
+
41
+ const blockHeight = await getBlockHeight({ assetName, insightClient })
42
+
43
+ const rbfEnabled = updatedFeeData.rbfEnabled && !isExchange && isRbfAllowed
44
+
45
+ const changeAddress = multipleAddressesEnabled
46
+ ? await assetClientInterface.getNextChangeAddress({ assetName, walletAccount })
47
+ : await assetClientInterface.getReceiveAddressObject({ assetName, walletAccount })
48
+
49
+ const txSet = await assetClientInterface.getTxLog({ assetName, walletAccount })
50
+
51
+ const unconfirmedTxAncestor = getUnconfirmedTxAncestorMap({ accountState })
52
+
53
+ const usableUtxos = getUsableUtxos({
54
+ asset,
55
+ utxos: getUtxos({ accountState, asset }),
56
+ feeData: updatedFeeData,
57
+ txSet,
58
+ unconfirmedTxAncestor,
59
+ })
60
+
61
+ return {
62
+ updatedFeeData,
63
+ blockHeight,
64
+ rbfEnabled,
65
+ changeAddress,
66
+ txSet,
67
+ unconfirmedTxAncestor,
68
+ usableUtxos,
69
+ }
70
+ }
71
+
72
+ // Process and normalize addresses
73
+ function processAddress({ asset, toAddress }) {
74
+ let processedAddress = toAddress
75
+
76
+ if (asset.address.toLegacyAddress) {
77
+ processedAddress = asset.address.toLegacyAddress(toAddress)
78
+ }
79
+
80
+ if (asset.name === 'digibyte' && asset.address.isP2SH2(processedAddress)) {
81
+ processedAddress = asset.address.P2SH2ToP2SH(processedAddress)
82
+ }
83
+
84
+ const useCashAddress = asset.address.isCashAddress?.(toAddress)
85
+
86
+ return { processedAddress, useCashAddress }
87
+ }
88
+
89
+ // Determine strategy for transaction bumping (RBF vs CPFP)
90
+ function determineBumpStrategy({ bumpTxId, replaceableTxs, usableUtxos }) {
91
+ if (!bumpTxId) {
92
+ return { replaceableTxs, utxosToBump: undefined }
93
+ }
94
+
95
+ // Check if we can use RBF (Replace-By-Fee)
96
+ const bumpTx = replaceableTxs.find(({ txId }) => txId === bumpTxId)
97
+
98
+ if (bumpTx) {
99
+ // Use RBF: replace the transaction directly
100
+ return { replaceableTxs: [bumpTx], utxosToBump: undefined }
101
+ }
102
+
103
+ // Otherwise try CPFP (Child-Pays-For-Parent) by spending the transaction's outputs
104
+ const utxosToBump = usableUtxos.getTxIdUtxos(bumpTxId)
105
+ if (utxosToBump.size === 0) {
106
+ throw new Error(`Cannot bump transaction ${bumpTxId}`)
107
+ }
108
+
109
+ return { replaceableTxs: [], utxosToBump }
110
+ }
111
+
112
+ // Prepare parameters for UTXO selection
113
+ function prepareUtxoSelectionParams({
114
+ bumpTxId,
115
+ asset,
116
+ amount,
117
+ replaceableTxs,
118
+ processedAddress,
119
+ changeAddressType,
120
+ updatedFeeData,
121
+ rbfEnabled,
122
+ feePerKB,
123
+ customFee,
124
+ isSendAll,
125
+ }) {
126
+ const sendAmount = bumpTxId ? asset.currency.ZERO : amount
127
+
128
+ const receiveAddress = bumpTxId
129
+ ? replaceableTxs.length > 0
130
+ ? null
131
+ : changeAddressType
132
+ : processedAddress
133
+
134
+ const feeRate = customFee || updatedFeeData.feePerKB
135
+ const resolvedIsSendAll = !rbfEnabled && feePerKB ? false : isSendAll
136
+
137
+ return {
138
+ sendAmount,
139
+ receiveAddress,
140
+ feeRate,
141
+ resolvedIsSendAll,
142
+ }
143
+ }
144
+
145
+ // Process RBF replacement transaction
146
+ function processReplacementTransaction({ replaceTx, asset }) {
147
+ if (!replaceTx) return { replaceTx: undefined, replaceTxInputUtxos: undefined }
148
+
149
+ const clonedTx = replaceTx.clone()
150
+ const updatedTx = clonedTx.update({ data: { ...clonedTx.data } })
151
+
152
+ updatedTx.data.sent = updatedTx.data.sent.map((to) => ({
153
+ ...to,
154
+ amount: serializeCurrency(to.amount, asset.currency),
155
+ }))
156
+
157
+ const replaceTxInputUtxos = UtxoCollection.fromJSON(updatedTx.data.inputs, {
158
+ currency: asset.currency,
159
+ })
160
+
161
+ return { replaceTx: updatedTx, replaceTxInputUtxos }
162
+ }
163
+
164
+ // Create transaction outputs
165
+ function createTransactionOutputs({
166
+ replaceTx,
167
+ processedAddress,
168
+ sendAmount,
169
+ asset,
170
+ selectedUtxos,
171
+ fee,
172
+ changeAddress,
173
+ }) {
174
+ const assetName = asset.name
175
+ let outputs = []
176
+ let sendOutput
177
+
178
+ // Add existing outputs from replacement transaction
179
+ if (replaceTx) {
180
+ outputs = replaceTx.data.sent.map(({ address, amount }) =>
181
+ createOutput(assetName, address, parseCurrency(amount, asset.currency))
182
+ )
183
+ }
184
+
185
+ // Add send output if we have a destination address
186
+ if (processedAddress) {
187
+ sendOutput = createOutput(assetName, processedAddress, sendAmount)
188
+ outputs.push(sendOutput)
189
+ }
190
+
191
+ // Calculate total amount
192
+ const totalAmount = replaceTx
193
+ ? replaceTx.data.sent.reduce(
194
+ (total, { amount }) => total.add(parseCurrency(amount, asset.currency)),
195
+ sendAmount
196
+ )
197
+ : sendAmount
198
+
199
+ // Handle change output
200
+ const { changeOutput, adjustedFee, changeAddressKeypath, ourAddress } = createChangeOutput({
201
+ selectedUtxos,
202
+ totalAmount,
203
+ fee,
204
+ replaceTx,
205
+ changeAddress,
206
+ asset,
207
+ })
208
+
209
+ if (changeOutput) {
210
+ outputs.push(changeOutput)
211
+ }
212
+
213
+ return {
214
+ outputs: replaceTx ? outputs : shuffle(outputs),
215
+ sendOutput,
216
+ changeOutput,
217
+ totalAmount,
218
+ adjustedFee,
219
+ changeAddressKeypath,
220
+ ourAddress,
221
+ }
222
+ }
223
+
224
+ // Create change output if needed
225
+ function createChangeOutput({ selectedUtxos, totalAmount, fee, replaceTx, changeAddress, asset }) {
226
+ const change = selectedUtxos.value.sub(totalAmount).sub(fee)
227
+ const dust = getChangeDustValue(asset)
228
+
229
+ // Process change address
230
+ let ourAddress = replaceTx?.data?.changeAddress || changeAddress
231
+ if (asset.address.toLegacyAddress) {
232
+ const legacyAddress = asset.address.toLegacyAddress(ourAddress.address)
233
+ ourAddress = Address.create(legacyAddress, ourAddress.meta)
234
+ }
235
+
236
+ // Create change output if above dust threshold
237
+ if (change.gte(dust)) {
238
+ const changeOutput = createOutput(
239
+ asset.name,
240
+ ourAddress.address ?? ourAddress.toString(),
241
+ change
242
+ )
243
+
244
+ // Return the keypath for hardware wallet change detection
245
+ return {
246
+ changeOutput,
247
+ adjustedFee: fee,
248
+ changeAddressKeypath: ourAddress.meta.path,
249
+ ourAddress,
250
+ }
251
+ }
252
+
253
+ // Add dust to fee if not enough for change output
254
+ return {
255
+ changeOutput: undefined,
256
+ adjustedFee: fee.add(change),
257
+ changeAddressKeypath: undefined,
258
+ ourAddress,
259
+ }
260
+ }
261
+
13
262
  async function createUnsignedTx({
14
263
  inputs,
15
264
  outputs,
@@ -51,8 +300,6 @@ const transferHandler = {
51
300
  walletAccount,
52
301
  toAddress,
53
302
  amount,
54
- blockHeight: providedBlockHeight,
55
- rbfEnabled: providedRbfEnabled,
56
303
  multipleAddressesEnabled,
57
304
  feePerKB,
58
305
  customFee,
@@ -70,99 +317,72 @@ const transferHandler = {
70
317
  changeAddressType,
71
318
  }) => {
72
319
  const assetName = asset.name
73
- const updatedFeeData = { ...feeData, feePerKB: feePerKB ?? feeData.feePerKB }
74
320
  const insightClient = asset.baseAsset.insightClient
75
321
 
76
- const blockHeight = providedBlockHeight || (await getBlockHeight({ assetName, insightClient }))
322
+ validateTransactionParams({ assetClientInterface, asset, toAddress, bumpTxId })
77
323
 
78
- const rbfEnabled =
79
- providedRbfEnabled || (updatedFeeData.rbfEnabled && !isExchange && isRbfAllowed)
80
-
81
- const shuffle = (list) => {
82
- // Using full lodash.shuffle notation so it can be mocked with spyOn in tests
83
- return lodash.shuffle(list)
84
- }
85
-
86
- assert(
324
+ const context = await prepareTransactionContext({
325
+ asset,
326
+ assetName,
327
+ walletAccount,
328
+ multipleAddressesEnabled,
87
329
  assetClientInterface,
88
- `assetClientInterface must be supplied in sendTx for ${asset.name}`
89
- )
90
- assert(
91
- toAddress || bumpTxId,
92
- 'should not be called without either a receiving toAddress or to bump a tx'
93
- )
330
+ accountState,
331
+ insightClient,
332
+ feeData,
333
+ feePerKB,
334
+ isExchange,
335
+ isRbfAllowed,
336
+ })
94
337
 
95
- const useCashAddress = asset.address.isCashAddress?.(toAddress)
338
+ const { processedAddress, useCashAddress } = processAddress({ asset, toAddress })
96
339
 
97
- const changeAddress = multipleAddressesEnabled
98
- ? await assetClientInterface.getNextChangeAddress({ assetName, walletAccount })
99
- : await assetClientInterface.getReceiveAddressObject({ assetName, walletAccount })
340
+ // Get replaceable transactions
341
+ let replaceableTxs = findUnconfirmedSentRbfTxs(context.txSet)
100
342
 
101
- const txSet = await assetClientInterface.getTxLog({ assetName, walletAccount })
343
+ // Determine bumping strategy (RBF or CPFP)
344
+ const { replaceableTxs: updatedReplaceableTxs, utxosToBump } = determineBumpStrategy({
345
+ bumpTxId,
346
+ replaceableTxs,
347
+ usableUtxos: context.usableUtxos,
348
+ })
349
+ replaceableTxs = updatedReplaceableTxs
102
350
 
103
- const unconfirmedTxAncestor = getUnconfirmedTxAncestorMap({ accountState })
104
- const usableUtxos = getUsableUtxos({
351
+ const utxoParams = prepareUtxoSelectionParams({
352
+ bumpTxId,
105
353
  asset,
106
- utxos: getUtxos({ accountState, asset }),
107
- feeData: updatedFeeData,
108
- txSet,
109
- unconfirmedTxAncestor,
354
+ amount,
355
+ replaceableTxs,
356
+ processedAddress,
357
+ changeAddressType,
358
+ updatedFeeData: context.updatedFeeData,
359
+ rbfEnabled: context.rbfEnabled,
360
+ feePerKB,
361
+ customFee,
362
+ isSendAll,
110
363
  })
111
364
 
112
- let replaceableTxs = findUnconfirmedSentRbfTxs(txSet)
113
-
114
- let processedAddress = toAddress
115
- if (asset.address.toLegacyAddress) {
116
- processedAddress = asset.address.toLegacyAddress(toAddress)
117
- }
118
-
119
- if (assetName === 'digibyte' && asset.address.isP2SH2(processedAddress)) {
120
- processedAddress = asset.address.P2SH2ToP2SH(processedAddress)
121
- }
122
-
123
- let utxosToBump
124
- if (bumpTxId) {
125
- const bumpTx = replaceableTxs.find(({ txId }) => txId === bumpTxId)
126
- if (bumpTx) {
127
- replaceableTxs = [bumpTx]
128
- } else {
129
- utxosToBump = usableUtxos.getTxIdUtxos(bumpTxId)
130
- if (utxosToBump.size === 0) {
131
- throw new Error(`Cannot bump transaction ${bumpTxId}`)
132
- }
133
-
134
- replaceableTxs = []
135
- }
136
- }
137
-
138
- const sendAmount = bumpTxId ? asset.currency.ZERO : amount
139
- const receiveAddress = bumpTxId
140
- ? replaceableTxs.length > 0
141
- ? null
142
- : changeAddressType
143
- : processedAddress
144
- const feeRate = updatedFeeData.feePerKB
145
- const resolvedIsSendAll = !rbfEnabled && feePerKB ? false : isSendAll
146
-
147
365
  let { selectedUtxos, fee, replaceTx } = selectUtxos({
148
366
  asset,
149
- usableUtxos,
367
+ usableUtxos: context.usableUtxos,
150
368
  replaceableTxs,
151
- amount: sendAmount,
152
- feeRate: customFee || feeRate,
153
- receiveAddress,
154
- isSendAll: resolvedIsSendAll,
369
+ amount: utxoParams.sendAmount,
370
+ feeRate: utxoParams.feeRate,
371
+ receiveAddress: utxoParams.receiveAddress,
372
+ isSendAll: utxoParams.resolvedIsSendAll,
155
373
  getFeeEstimator: (asset, { feePerKB, ...options }) =>
156
374
  getFeeEstimator(asset, feePerKB, options),
157
375
  mustSpendUtxos: utxosToBump,
158
376
  allowUnconfirmedRbfEnabledUtxos,
159
- unconfirmedTxAncestor,
377
+ unconfirmedTxAncestor: context.unconfirmedTxAncestor,
160
378
  utxosDescendingOrder,
161
379
  taprootInputWitnessSize,
162
380
  changeAddressType,
163
381
  })
164
382
 
165
- if (!selectedUtxos && !replaceTx) throw new Error('Not enough funds.')
383
+ if (!selectedUtxos && !replaceTx) {
384
+ throw new Error('Not enough funds.')
385
+ }
166
386
 
167
387
  // When bumping a tx, we can either replace the tx with RBF or spend its selected change.
168
388
  // If there is no selected UTXO or the tx to replace is not the tx we want to bump,
@@ -173,90 +393,76 @@ const transferHandler = {
173
393
  throw new Error(`Unable to bump ${bumpTxId}`)
174
394
  }
175
395
 
176
- if (replaceTx) {
177
- replaceTx = replaceTx.clone()
178
- replaceTx = replaceTx.update({ data: { ...replaceTx.data } })
179
- replaceTx.data.sent = replaceTx.data.sent.map((to) => {
180
- return { ...to, amount: serializeCurrency(to.amount, asset.currency) }
181
- })
182
- selectedUtxos = selectedUtxos.union(
183
- UtxoCollection.fromJSON(replaceTx.data.inputs, { currency: asset.currency })
184
- )
396
+ const { replaceTx: processedReplaceTx, replaceTxInputUtxos } = processReplacementTransaction({
397
+ replaceTx,
398
+ asset,
399
+ })
400
+
401
+ if (replaceTxInputUtxos) {
402
+ selectedUtxos = selectedUtxos.union(replaceTxInputUtxos)
185
403
  }
186
404
 
187
405
  const addressPathsMap = selectedUtxos.getAddressPathsMap()
188
406
 
189
- // Inputs and Outputs
190
- const inputs = shuffle(createInputs(assetName, selectedUtxos.toArray(), rbfEnabled))
191
- let outputs = replaceTx
192
- ? replaceTx.data.sent.map(({ address, amount }) =>
193
- createOutput(assetName, address, parseCurrency(amount, asset.currency))
194
- )
195
- : []
196
-
197
- // Send output
198
- let sendOutput
199
- if (processedAddress) {
200
- sendOutput = createOutput(assetName, processedAddress, sendAmount)
201
- outputs.push(sendOutput)
202
- }
407
+ // Create inputs
408
+ const inputs = shuffle(createInputs(assetName, selectedUtxos.toArray(), context.rbfEnabled))
203
409
 
204
- const totalAmount = replaceTx
205
- ? replaceTx.data.sent.reduce(
206
- (total, { amount }) => total.add(parseCurrency(amount, asset.currency)),
207
- sendAmount
208
- )
209
- : sendAmount
210
-
211
- const change = selectedUtxos.value.sub(totalAmount).sub(fee)
212
- const dust = getChangeDustValue(asset)
213
- let ourAddress = replaceTx?.data?.changeAddress || changeAddress
214
- if (asset.address.toLegacyAddress) {
215
- const legacyAddress = asset.address.toLegacyAddress(ourAddress.address)
216
- ourAddress = Address.create(legacyAddress, ourAddress.meta)
217
- }
410
+ // Create outputs
411
+ const {
412
+ outputs,
413
+ sendOutput,
414
+ changeOutput,
415
+ totalAmount,
416
+ adjustedFee,
417
+ changeAddressKeypath,
418
+ ourAddress,
419
+ } = createTransactionOutputs({
420
+ replaceTx: processedReplaceTx,
421
+ processedAddress,
422
+ sendAmount: utxoParams.sendAmount,
423
+ asset,
424
+ selectedUtxos,
425
+ fee,
426
+ changeAddress: context.changeAddress,
427
+ })
218
428
 
219
- // Change Output
220
- let changeOutput
221
- if (change.gte(dust)) {
222
- changeOutput = createOutput(assetName, ourAddress.address ?? ourAddress.toString(), change)
223
- // Add the keypath of change address to support Trezor detect the change output.
224
- // Output is change and does not need approval from user which shows the strange address that user never seen.
225
- addressPathsMap[changeAddress] = ourAddress.meta.path
226
- outputs.push(changeOutput)
227
- } else {
228
- // If we don't have enough for a change output, then all remaining dust is just added to fee
229
- fee = fee.add(change)
429
+ // Add the keypath of change address to support Trezor detect the change output.
430
+ // Output is change and does not need approval from user which shows the strange address that user never seen.
431
+ if (changeAddressKeypath) {
432
+ addressPathsMap[context.changeAddress] = changeAddressKeypath
230
433
  }
231
434
 
232
- outputs = replaceTx ? outputs : shuffle(outputs)
233
-
435
+ // Create unsigned transaction
234
436
  const unsignedTx = await createUnsignedTx({
235
437
  inputs,
236
438
  outputs,
237
439
  useCashAddress,
238
440
  addressPathsMap,
239
- blockHeight,
441
+ blockHeight: context.blockHeight,
240
442
  asset,
241
443
  selectedUtxos,
242
444
  insightClient,
243
445
  })
244
446
 
245
447
  return {
246
- amount,
247
- change,
248
- totalAmount,
249
- address: processedAddress,
250
- ourAddress,
251
- receiveAddress,
252
- sendAmount,
253
- fee,
254
- usableUtxos,
255
- selectedUtxos,
256
- replaceTx,
257
- sendOutput,
258
- changeOutput,
259
448
  unsignedTx,
449
+ fee: adjustedFee,
450
+ metadata: {
451
+ amount,
452
+ change: selectedUtxos.value.sub(totalAmount).sub(adjustedFee),
453
+ totalAmount,
454
+ address: processedAddress,
455
+ ourAddress,
456
+ receiveAddress: utxoParams.receiveAddress,
457
+ sendAmount: utxoParams.sendAmount,
458
+ usableUtxos: context.usableUtxos,
459
+ selectedUtxos,
460
+ replaceTx: processedReplaceTx,
461
+ sendOutput,
462
+ changeOutput,
463
+ blockHeight: context.blockHeight,
464
+ rbfEnabled: context.rbfEnabled,
465
+ },
260
466
  }
261
467
  },
262
468
  }
@@ -275,8 +481,6 @@ export const createTxFactory =
275
481
  type,
276
482
  toAddress,
277
483
  amount,
278
- blockHeight,
279
- rbfEnabled,
280
484
  multipleAddressesEnabled,
281
485
  feePerKB,
282
486
  customFee,
@@ -300,8 +504,6 @@ export const createTxFactory =
300
504
  walletAccount,
301
505
  toAddress,
302
506
  amount,
303
- blockHeight,
304
- rbfEnabled,
305
507
  multipleAddressesEnabled,
306
508
  feePerKB,
307
509
  customFee,
@@ -0,0 +1,48 @@
1
+ import { retry } from '@exodus/simple-retry'
2
+
3
+ /**
4
+ * Broadcast a signed transaction to the Bitcoin network with retry logic
5
+ * @param {Object} params
6
+ * @param {Object} params.asset - The asset object
7
+ * @param {Buffer} params.rawTx - The raw signed transaction
8
+ * @returns {Promise<void>}
9
+ * @throws {Error} With finalError=true for non-retryable errors
10
+ */
11
+ export async function broadcastTransaction({ asset, rawTx }) {
12
+ const broadcastTxWithRetry = retry(
13
+ async (rawTxHex) => {
14
+ try {
15
+ return await asset.api.broadcastTx(rawTxHex)
16
+ } catch (e) {
17
+ // Mark certain errors as final (non-retryable)
18
+ if (
19
+ /missing inputs/i.test(e.message) ||
20
+ /absurdly-high-fee/.test(e.message) ||
21
+ /too-long-mempool-chain/.test(e.message) ||
22
+ /txn-mempool-conflict/.test(e.message) ||
23
+ /tx-size/.test(e.message) ||
24
+ /txn-already-in-mempool/.test(e.message)
25
+ ) {
26
+ e.finalError = true
27
+ }
28
+
29
+ throw e
30
+ }
31
+ },
32
+ { delayTimesMs: ['10s'] }
33
+ )
34
+
35
+ const rawTxHex = rawTx.toString('hex')
36
+
37
+ try {
38
+ await broadcastTxWithRetry(rawTxHex)
39
+ } catch (err) {
40
+ if (err.message.includes('txn-already-in-mempool')) {
41
+ // Not an error, transaction is already broadcast
42
+ console.log('Transaction is already in the mempool.')
43
+ return
44
+ }
45
+
46
+ throw err
47
+ }
48
+ }
@@ -1,11 +1,9 @@
1
1
  import * as defaultBitcoinjsLib from '@exodus/bitcoinjs'
2
- import { Address } from '@exodus/models'
3
- import { retry } from '@exodus/simple-retry'
4
2
  import assert from 'minimalistic-assert'
5
3
 
6
- import { serializeCurrency } from '../fee/fee-utils.js'
7
4
  import { createTxFactory } from '../tx-create/create-tx.js'
8
- import { getBlockHeight } from '../tx-create/tx-create-utils.js'
5
+ import { broadcastTransaction } from './broadcast-tx.js'
6
+ import { updateAccountState, updateTransactionLog } from './update-state.js'
9
7
 
10
8
  const getSize = (tx) => {
11
9
  if (typeof tx.size === 'number') return tx.size
@@ -105,11 +103,9 @@ const getPrepareSendTransaction = async ({
105
103
  amount,
106
104
  asset,
107
105
  assetClientInterface,
108
- blockHeight,
109
106
  changeAddressType,
110
107
  getFeeEstimator,
111
108
  options,
112
- rbfEnabled,
113
109
  utxosDescendingOrder,
114
110
  walletAccount,
115
111
  }) => {
@@ -121,15 +117,17 @@ const getPrepareSendTransaction = async ({
121
117
  changeAddressType,
122
118
  })
123
119
 
120
+ // Set default values for options
121
+ const { isRbfAllowed = true, ...restOptions } = options || {}
122
+
124
123
  return createTx({
125
124
  asset,
126
125
  walletAccount,
127
126
  type: 'transfer',
128
127
  toAddress: address,
129
128
  amount,
130
- blockHeight,
131
- rbfEnabled,
132
- ...options,
129
+ isRbfAllowed,
130
+ ...restOptions,
133
131
  })
134
132
  }
135
133
 
@@ -146,17 +144,10 @@ export const createAndBroadcastTXFactory =
146
144
  }) =>
147
145
  async ({ asset, walletAccount, address, amount, options }) => {
148
146
  // Prepare transaction
149
- const { bumpTxId, isExchange, isRbfAllowed = true } = options
147
+ const { bumpTxId } = options
150
148
 
151
149
  const assetName = asset.name
152
- const feeData = await assetClientInterface.getFeeConfig({ assetName })
153
150
  const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
154
- const insightClient = asset.baseAsset.insightClient
155
-
156
- const rbfEnabled = feeData.rbfEnabled && !isExchange && isRbfAllowed
157
-
158
- // blockHeight
159
- const blockHeight = await getBlockHeight({ assetName, insightClient })
160
151
 
161
152
  const transactionDescriptor = await getPrepareSendTransaction({
162
153
  address,
@@ -164,31 +155,27 @@ export const createAndBroadcastTXFactory =
164
155
  amount,
165
156
  asset,
166
157
  assetClientInterface,
167
- blockHeight,
168
158
  changeAddressType,
169
159
  getFeeEstimator,
170
160
  options,
171
- rbfEnabled,
172
161
  utxosDescendingOrder,
173
162
  walletAccount,
174
163
  })
164
+
165
+ const { unsignedTx, fee, metadata } = transactionDescriptor
175
166
  const {
176
- change,
177
- totalAmount,
178
- ourAddress,
179
- receiveAddress,
180
167
  sendAmount,
181
- fee,
182
168
  usableUtxos,
183
- selectedUtxos,
184
169
  replaceTx,
185
170
  sendOutput,
186
171
  changeOutput,
187
- unsignedTx,
188
- } = transactionDescriptor
172
+ blockHeight,
173
+ rbfEnabled,
174
+ } = metadata
175
+
189
176
  const outputs = unsignedTx.txData.outputs
190
177
 
191
- address = transactionDescriptor.address
178
+ address = metadata.address
192
179
 
193
180
  // Sign transaction
194
181
  const { rawTx, txId, tx } = await signTransaction({
@@ -199,44 +186,18 @@ export const createAndBroadcastTXFactory =
199
186
  })
200
187
 
201
188
  // Broadcast transaction
202
- const broadcastTxWithRetry = retry(
203
- async (rawTx) => {
204
- try {
205
- return await asset.api.broadcastTx(rawTx)
206
- } catch (e) {
207
- if (
208
- /missing inputs/i.test(e.message) ||
209
- /absurdly-high-fee/.test(e.message) ||
210
- /too-long-mempool-chain/.test(e.message) ||
211
- /txn-mempool-conflict/.test(e.message) ||
212
- /tx-size/.test(e.message) ||
213
- /txn-already-in-mempool/.test(e.message)
214
- ) {
215
- e.finalError = true
216
- }
217
-
218
- throw e
219
- }
220
- },
221
- { delayTimesMs: ['10s'] }
222
- )
223
-
224
189
  try {
225
- await broadcastTxWithRetry(rawTx.toString('hex'))
190
+ await broadcastTransaction({ asset, rawTx })
226
191
  } catch (err) {
227
- if (err.message.includes('txn-already-in-mempool')) {
228
- // It's not an error, we must ignore it.
229
- console.log('Transaction is already in the mempool.')
230
- } else if (/insight broadcast http error.*missing inputs/i.test(err.message)) {
192
+ if (/insight broadcast http error.*missing inputs/i.test(err.message)) {
231
193
  err.txInfo = JSON.stringify({
232
194
  amount: sendAmount.toDefaultString({ unit: true }),
233
- fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(), // todo why does 0 not have a unit? Is default unit ok here?
195
+ fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(),
234
196
  allUtxos: usableUtxos.toJSON(),
235
197
  })
236
- throw err
237
- } else {
238
- throw err
239
198
  }
199
+
200
+ throw err
240
201
  }
241
202
 
242
203
  function findUtxoIndex(output) {
@@ -256,114 +217,35 @@ export const createAndBroadcastTXFactory =
256
217
  const changeUtxoIndex = findUtxoIndex(changeOutput)
257
218
  const sendUtxoIndex = findUtxoIndex(sendOutput)
258
219
 
259
- const { script, size } = getSizeAndChangeScript({ assetName, tx, rawTx, changeUtxoIndex, txId })
260
-
261
- const knownBalanceUtxoIds = accountState.knownBalanceUtxoIds || []
262
- let remainingUtxos = usableUtxos.difference(selectedUtxos)
263
- if (changeUtxoIndex !== -1) {
264
- const address = Address.create(ourAddress.address, ourAddress.meta)
265
- const changeUtxo = {
266
- txId,
267
- address,
268
- vout: changeUtxoIndex,
269
- script,
270
- value: change,
271
- confirmations: 0,
272
- rbfEnabled,
273
- }
274
-
275
- knownBalanceUtxoIds.push(`${changeUtxo.txId}:${changeUtxo.vout}`.toLowerCase())
276
- remainingUtxos = remainingUtxos.addUtxo(changeUtxo)
277
- }
278
-
279
- if (replaceTx) {
280
- remainingUtxos = remainingUtxos.difference(remainingUtxos.getTxIdUtxos(replaceTx.txId))
281
- }
282
-
283
- await assetClientInterface.updateAccountState({
284
- assetName,
285
- walletAccount,
286
- newData: {
287
- utxos: remainingUtxos,
288
- knownBalanceUtxoIds,
289
- },
290
- })
291
-
292
- const config = await assetClientInterface.getAssetConfig?.({
220
+ const { size } = await updateAccountState({
221
+ assetClientInterface,
293
222
  assetName,
294
223
  walletAccount,
224
+ accountState,
225
+ txId,
226
+ metadata,
227
+ tx,
228
+ rawTx,
229
+ changeUtxoIndex,
230
+ getSizeAndChangeScript,
231
+ rbfEnabled,
295
232
  })
296
- const walletAddressObjects = await assetClientInterface.getReceiveAddresses({
297
- walletAccount,
298
- assetName,
299
- multiAddressMode: config?.multiAddressMode ?? true,
300
- })
301
- // There are two cases of bumping, replacing or chaining a self-send.
302
- // If we have a bumpTxId, but we aren't replacing, then it is a self-send.
303
- const selfSend = bumpTxId
304
- ? !replaceTx
305
- : walletAddressObjects.some((receiveAddress) => String(receiveAddress) === String(address))
306
-
307
- const displayReceiveAddress = asset.address.displayAddress?.(receiveAddress) || receiveAddress
308
-
309
- const receivers = bumpTxId
310
- ? replaceTx
311
- ? replaceTx.data.sent
312
- : []
313
- : replaceTx
314
- ? [
315
- ...replaceTx.data.sent,
316
- { address: displayReceiveAddress, amount: serializeCurrency(amount, asset.currency) },
317
- ]
318
- : [{ address: displayReceiveAddress, amount: serializeCurrency(amount, asset.currency) }]
319
233
 
320
- const calculateCoinAmount = () => {
321
- if (selfSend) {
322
- return asset.currency.ZERO
323
- }
324
-
325
- return totalAmount.abs().negate()
326
- }
327
-
328
- const coinAmount = calculateCoinAmount()
329
-
330
- await assetClientInterface.updateTxLogAndNotify({
331
- assetName: asset.name,
234
+ await updateTransactionLog({
235
+ asset,
236
+ assetClientInterface,
332
237
  walletAccount,
333
- txs: [
334
- {
335
- txId,
336
- confirmations: 0,
337
- coinAmount,
338
- coinName: asset.name,
339
- feeAmount: fee,
340
- feeCoinName: assetName,
341
- selfSend,
342
- data: {
343
- sent: selfSend ? [] : receivers,
344
- rbfEnabled,
345
- feePerKB: size ? fee.div(size / 1000).toBaseNumber() : undefined,
346
- changeAddress: changeOutput ? ourAddress : undefined,
347
- blockHeight,
348
- blocksSeen: 0,
349
- inputs: selectedUtxos.toJSON(),
350
- replacedTxId: replaceTx ? replaceTx.txId : undefined,
351
- },
352
- },
353
- ],
238
+ txId,
239
+ fee,
240
+ metadata,
241
+ address,
242
+ amount,
243
+ bumpTxId,
244
+ size,
245
+ blockHeight,
246
+ rbfEnabled,
354
247
  })
355
248
 
356
- // If we are replacing the tx, add the replacedBy info to the previous tx to update UI
357
- // Also, clone the personal note and attach it to the new tx so it is not lost
358
- if (replaceTx) {
359
- replaceTx.data.replacedBy = txId
360
- await assetClientInterface.updateTxLogAndNotify({
361
- assetName,
362
- walletAccount,
363
- txs: [replaceTx],
364
- })
365
- }
366
-
367
249
  return {
368
250
  txId,
369
251
  sendUtxoIndex,
@@ -0,0 +1,188 @@
1
+ import { Address } from '@exodus/models'
2
+
3
+ import { serializeCurrency } from '../fee/fee-utils.js'
4
+
5
+ /**
6
+ * Update account state after transaction is broadcast
7
+ * @param {Object} params
8
+ * @param {Object} params.assetClientInterface - Asset client interface
9
+ * @param {string} params.assetName - Name of the asset
10
+ * @param {Object} params.walletAccount - Wallet account
11
+ * @param {Object} params.accountState - Current account state
12
+ * @param {string} params.txId - Transaction ID
13
+ * @param {Object} params.metadata - Transaction metadata
14
+ * @param {Object} params.tx - Signed transaction object
15
+ * @param {Buffer} params.rawTx - Raw transaction
16
+ * @param {number} params.changeUtxoIndex - Index of change output
17
+ * @param {Object} params.changeOutput - Change output details
18
+ * @param {Object} params.getSizeAndChangeScript - Function to get size and script
19
+ * @param {boolean} params.rbfEnabled - Whether RBF is enabled
20
+ */
21
+ export async function updateAccountState({
22
+ assetClientInterface,
23
+ assetName,
24
+ walletAccount,
25
+ accountState,
26
+ txId,
27
+ metadata,
28
+ tx,
29
+ rawTx,
30
+ changeUtxoIndex,
31
+ getSizeAndChangeScript,
32
+ rbfEnabled,
33
+ }) {
34
+ const { usableUtxos, selectedUtxos, replaceTx, change, ourAddress } = metadata
35
+
36
+ // Get change script and size
37
+ const { script, size } = getSizeAndChangeScript({
38
+ assetName,
39
+ tx,
40
+ rawTx,
41
+ changeUtxoIndex,
42
+ txId,
43
+ })
44
+
45
+ // Update remaining UTXOs
46
+ const knownBalanceUtxoIds = accountState.knownBalanceUtxoIds || []
47
+ let remainingUtxos = usableUtxos.difference(selectedUtxos)
48
+
49
+ // Add change UTXO if present
50
+ if (changeUtxoIndex !== -1 && ourAddress) {
51
+ const address = Address.create(ourAddress.address || ourAddress, ourAddress.meta || {})
52
+ const changeUtxo = {
53
+ txId,
54
+ address,
55
+ vout: changeUtxoIndex,
56
+ script,
57
+ value: change,
58
+ confirmations: 0,
59
+ rbfEnabled,
60
+ }
61
+
62
+ knownBalanceUtxoIds.push(`${changeUtxo.txId}:${changeUtxo.vout}`.toLowerCase())
63
+ remainingUtxos = remainingUtxos.addUtxo(changeUtxo)
64
+ }
65
+
66
+ // Remove replaced transaction UTXOs if present
67
+ if (replaceTx) {
68
+ remainingUtxos = remainingUtxos.difference(remainingUtxos.getTxIdUtxos(replaceTx.txId))
69
+ }
70
+
71
+ // Update account state
72
+ await assetClientInterface.updateAccountState({
73
+ assetName,
74
+ walletAccount,
75
+ newData: {
76
+ utxos: remainingUtxos,
77
+ knownBalanceUtxoIds,
78
+ },
79
+ })
80
+
81
+ return { size }
82
+ }
83
+
84
+ /**
85
+ * Update transaction log with new transaction
86
+ * @param {Object} params
87
+ * @param {Object} params.asset - Asset object
88
+ * @param {Object} params.assetClientInterface - Asset client interface
89
+ * @param {Object} params.walletAccount - Wallet account
90
+ * @param {string} params.txId - Transaction ID
91
+ * @param {Object} params.fee - Transaction fee
92
+ * @param {Object} params.metadata - Transaction metadata
93
+ * @param {string} params.address - Recipient address
94
+ * @param {Object} params.amount - Transaction amount (for regular sends)
95
+ * @param {string} params.bumpTxId - ID of transaction being bumped (if applicable)
96
+ * @param {number} params.size - Transaction size
97
+ * @param {number} params.blockHeight - Block height
98
+ * @param {boolean} params.rbfEnabled - Whether RBF is enabled
99
+ */
100
+ export async function updateTransactionLog({
101
+ asset,
102
+ assetClientInterface,
103
+ walletAccount,
104
+ txId,
105
+ fee,
106
+ metadata,
107
+ address,
108
+ amount,
109
+ bumpTxId,
110
+ size,
111
+ blockHeight,
112
+ rbfEnabled,
113
+ }) {
114
+ const { totalAmount, selectedUtxos, replaceTx, changeOutput, ourAddress } = metadata
115
+ const assetName = asset.name
116
+
117
+ // Check if this is a self-send
118
+ const config = await assetClientInterface.getAssetConfig?.({
119
+ assetName,
120
+ walletAccount,
121
+ })
122
+
123
+ const walletAddressObjects = await assetClientInterface.getReceiveAddresses({
124
+ walletAccount,
125
+ assetName,
126
+ multiAddressMode: config?.multiAddressMode ?? true,
127
+ })
128
+
129
+ // There are two cases of bumping, replacing or chaining a self-send.
130
+ // If we have a bumpTxId, but we aren't replacing, then it is a self-send.
131
+ const selfSend = bumpTxId
132
+ ? !replaceTx
133
+ : walletAddressObjects.some((receiveAddress) => String(receiveAddress) === String(address))
134
+
135
+ const displayReceiveAddress = asset.address.displayAddress?.(address) || address
136
+
137
+ // Build receivers list
138
+ const receivers = bumpTxId
139
+ ? replaceTx
140
+ ? replaceTx.data.sent
141
+ : []
142
+ : replaceTx
143
+ ? [
144
+ ...replaceTx.data.sent,
145
+ { address: displayReceiveAddress, amount: serializeCurrency(amount, asset.currency) },
146
+ ]
147
+ : [{ address: displayReceiveAddress, amount: serializeCurrency(amount, asset.currency) }]
148
+
149
+ // Calculate coin amount
150
+ const coinAmount = selfSend ? asset.currency.ZERO : totalAmount.abs().negate()
151
+
152
+ // Update transaction log
153
+ await assetClientInterface.updateTxLogAndNotify({
154
+ assetName,
155
+ walletAccount,
156
+ txs: [
157
+ {
158
+ txId,
159
+ confirmations: 0,
160
+ coinAmount,
161
+ coinName: asset.name,
162
+ feeAmount: fee,
163
+ feeCoinName: assetName,
164
+ selfSend,
165
+ data: {
166
+ sent: selfSend ? [] : receivers,
167
+ rbfEnabled,
168
+ feePerKB: size ? fee.div(size / 1000).toBaseNumber() : undefined,
169
+ changeAddress: changeOutput ? ourAddress : undefined,
170
+ blockHeight,
171
+ blocksSeen: 0,
172
+ inputs: selectedUtxos.toJSON(),
173
+ replacedTxId: replaceTx ? replaceTx.txId : undefined,
174
+ },
175
+ },
176
+ ],
177
+ })
178
+
179
+ // If replacing a transaction, update the old one
180
+ if (replaceTx) {
181
+ replaceTx.data.replacedBy = txId
182
+ await assetClientInterface.updateTxLogAndNotify({
183
+ assetName,
184
+ walletAccount,
185
+ txs: [replaceTx],
186
+ })
187
+ }
188
+ }