@exodus/bitcoin-api 4.1.3 → 4.1.4

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,14 @@
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.4](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.1.3...@exodus/bitcoin-api@4.1.4) (2025-10-15)
7
+
8
+ **Note:** Version bump only for package @exodus/bitcoin-api
9
+
10
+
11
+
12
+
13
+
6
14
  ## [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
15
 
8
16
 
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.4",
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": "0efd644cc413c308688b43c8563b172b3a80e3fa"
64
64
  }
@@ -243,20 +243,22 @@ const transferHandler = {
243
243
  })
244
244
 
245
245
  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
246
  unsignedTx,
247
+ fee,
248
+ metadata: {
249
+ amount,
250
+ change,
251
+ totalAmount,
252
+ address: processedAddress,
253
+ ourAddress,
254
+ receiveAddress,
255
+ sendAmount,
256
+ usableUtxos,
257
+ selectedUtxos,
258
+ replaceTx,
259
+ sendOutput,
260
+ changeOutput,
261
+ },
260
262
  }
261
263
  },
262
264
  }
@@ -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,10 @@
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
5
  import { getBlockHeight } from '../tx-create/tx-create-utils.js'
6
+ import { broadcastTransaction } from './broadcast-tx.js'
7
+ import { updateAccountState, updateTransactionLog } from './update-state.js'
9
8
 
10
9
  const getSize = (tx) => {
11
10
  if (typeof tx.size === 'number') return tx.size
@@ -172,23 +171,13 @@ export const createAndBroadcastTXFactory =
172
171
  utxosDescendingOrder,
173
172
  walletAccount,
174
173
  })
175
- const {
176
- change,
177
- totalAmount,
178
- ourAddress,
179
- receiveAddress,
180
- sendAmount,
181
- fee,
182
- usableUtxos,
183
- selectedUtxos,
184
- replaceTx,
185
- sendOutput,
186
- changeOutput,
187
- unsignedTx,
188
- } = transactionDescriptor
174
+
175
+ const { unsignedTx, fee, metadata } = transactionDescriptor
176
+ const { sendAmount, usableUtxos, replaceTx, sendOutput, changeOutput } = metadata
177
+
189
178
  const outputs = unsignedTx.txData.outputs
190
179
 
191
- address = transactionDescriptor.address
180
+ address = metadata.address
192
181
 
193
182
  // Sign transaction
194
183
  const { rawTx, txId, tx } = await signTransaction({
@@ -199,44 +188,18 @@ export const createAndBroadcastTXFactory =
199
188
  })
200
189
 
201
190
  // 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
191
  try {
225
- await broadcastTxWithRetry(rawTx.toString('hex'))
192
+ await broadcastTransaction({ asset, rawTx })
226
193
  } 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)) {
194
+ if (/insight broadcast http error.*missing inputs/i.test(err.message)) {
231
195
  err.txInfo = JSON.stringify({
232
196
  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?
197
+ fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(),
234
198
  allUtxos: usableUtxos.toJSON(),
235
199
  })
236
- throw err
237
- } else {
238
- throw err
239
200
  }
201
+
202
+ throw err
240
203
  }
241
204
 
242
205
  function findUtxoIndex(output) {
@@ -256,114 +219,35 @@ export const createAndBroadcastTXFactory =
256
219
  const changeUtxoIndex = findUtxoIndex(changeOutput)
257
220
  const sendUtxoIndex = findUtxoIndex(sendOutput)
258
221
 
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?.({
222
+ const { size } = await updateAccountState({
223
+ assetClientInterface,
293
224
  assetName,
294
225
  walletAccount,
226
+ accountState,
227
+ txId,
228
+ metadata,
229
+ tx,
230
+ rawTx,
231
+ changeUtxoIndex,
232
+ getSizeAndChangeScript,
233
+ rbfEnabled,
295
234
  })
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
-
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
235
 
330
- await assetClientInterface.updateTxLogAndNotify({
331
- assetName: asset.name,
236
+ await updateTransactionLog({
237
+ asset,
238
+ assetClientInterface,
332
239
  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
- ],
240
+ txId,
241
+ fee,
242
+ metadata,
243
+ address,
244
+ amount,
245
+ bumpTxId,
246
+ size,
247
+ blockHeight,
248
+ rbfEnabled,
354
249
  })
355
250
 
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
251
  return {
368
252
  txId,
369
253
  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
+ }