@exodus/bitcoin-api 4.9.0 → 4.9.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,26 @@
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.9.2](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.1...@exodus/bitcoin-api@4.9.2) (2026-01-28)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix(bitcoin-api): Guard BTC-like send against duplicate broadcast (#7335)
13
+
14
+
15
+
16
+ ## [4.9.1](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.0...@exodus/bitcoin-api@4.9.1) (2026-01-26)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix(bitcoin-api): add safe-string errors for Insight failures (#7320)
23
+
24
+
25
+
6
26
  ## [4.9.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.8.3...@exodus/bitcoin-api@4.9.0) (2025-12-23)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.9.0",
3
+ "version": "4.9.2",
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",
@@ -33,6 +33,7 @@
33
33
  "@exodus/i18n-dummy": "^1.0.0",
34
34
  "@exodus/key-identifier": "^1.3.0",
35
35
  "@exodus/models": "^12.0.1",
36
+ "@exodus/safe-string": "^1.4.0",
36
37
  "@exodus/send-validation-model": "^1.0.0",
37
38
  "@exodus/simple-retry": "^0.0.6",
38
39
  "bech32": "^1.1.3",
@@ -60,5 +61,5 @@
60
61
  "type": "git",
61
62
  "url": "git+https://github.com/ExodusMovement/assets.git"
62
63
  },
63
- "gitHead": "39b743562bc1171bf3d145b47382db9023c660dd"
64
+ "gitHead": "c4fddbe912ef8be359ae9952d8ff9945ed72c927"
64
65
  }
@@ -1,4 +1,3 @@
1
- import NumberUnit from '@exodus/currency'
2
1
  import { UtxoCollection } from '@exodus/models'
3
2
  import lodash from 'lodash'
4
3
  import assert from 'minimalistic-assert'
@@ -68,12 +67,10 @@ export const selectUtxos = ({
68
67
  const changeUtxos = usableUtxos.getTxIdUtxos(tx.txId)
69
68
  // Don't replace a tx that has already been spent
70
69
  if (tx.data.changeAddress && changeUtxos.size === 0) continue
71
- let feePerKB
72
- if (tx.data.feePerKB + MIN_RELAY_FEE > feeRate.toBaseNumber()) {
73
- feePerKB = new NumberUnit(tx.data.feePerKB + MIN_RELAY_FEE, asset.currency.baseUnit)
74
- } else {
75
- feePerKB = feeRate
76
- }
70
+ const feePerKB =
71
+ tx.data.feePerKB + MIN_RELAY_FEE > feeRate.toBaseNumber()
72
+ ? currency.baseUnit(tx.data.feePerKB + MIN_RELAY_FEE)
73
+ : feeRate
77
74
 
78
75
  const replaceFeeEstimator = getFeeEstimator(asset, { feePerKB, unconfirmedTxAncestor })
79
76
  const inputs = UtxoCollection.fromJSON(tx.data.inputs, { currency })
@@ -1,3 +1,4 @@
1
+ import { safeString } from '@exodus/safe-string'
1
2
  import { retry } from '@exodus/simple-retry'
2
3
  import delay from 'delay'
3
4
  import lodash from 'lodash'
@@ -5,16 +6,29 @@ import urlJoin from 'url-join'
5
6
 
6
7
  const { isEmpty } = lodash
7
8
 
8
- const getTextFromResponse = async (response) => {
9
- try {
10
- const responseBody = await response.text()
11
- return responseBody.slice(0, 100)
12
- } catch {
13
- return ''
14
- }
15
- }
16
-
17
- const fetchJson = async (url, fetchOptions, nullWhen404) => {
9
+ const INSIGHT_HTTP_ERROR_MESSAGE = safeString`insight-api-http-error`
10
+ const INSIGHT_JSON_ERROR_MESSAGE = safeString`insight-api-invalid-json`
11
+ const INSIGHT_MISSING_TXID_MESSAGE = safeString`insight-api-missing-txid`
12
+ const INSIGHT_HTTP_ERROR_BALANCE_MESSAGE = safeString`insight-api-http-error:balance`
13
+ const INSIGHT_HTTP_ERROR_STATUS_MESSAGE = safeString`insight-api-http-error:status`
14
+ const INSIGHT_HTTP_ERROR_ADDR_MESSAGE = safeString`insight-api-http-error:addr`
15
+ const INSIGHT_HTTP_ERROR_UTXO_MESSAGE = safeString`insight-api-http-error:utxo`
16
+ const INSIGHT_HTTP_ERROR_TX_MESSAGE = safeString`insight-api-http-error:tx`
17
+ const INSIGHT_HTTP_ERROR_FULLTX_MESSAGE = safeString`insight-api-http-error:fulltx`
18
+ const INSIGHT_HTTP_ERROR_RAWTX_MESSAGE = safeString`insight-api-http-error:rawtx`
19
+ const INSIGHT_HTTP_ERROR_ADDRS_TXS_MESSAGE = safeString`insight-api-http-error:addrs-txs`
20
+ const INSIGHT_HTTP_ERROR_UNCONFIRMED_ANCESTOR_MESSAGE = safeString`insight-api-http-error:unconfirmed-ancestor`
21
+ const INSIGHT_HTTP_ERROR_FEES_MESSAGE = safeString`insight-api-http-error:fees`
22
+ const INSIGHT_HTTP_ERROR_BROADCAST_MESSAGE = safeString`insight-api-http-error:broadcast`
23
+ const INSIGHT_HTTP_ERROR_CLAIMABLE_MESSAGE = safeString`insight-api-http-error:claimable`
24
+ const INSIGHT_HTTP_ERROR_UNCLAIMED_MESSAGE = safeString`insight-api-http-error:unclaimed`
25
+
26
+ const fetchJson = async (
27
+ url,
28
+ fetchOptions,
29
+ nullWhen404,
30
+ httpErrorMessage = INSIGHT_HTTP_ERROR_MESSAGE
31
+ ) => {
18
32
  const response = await fetch(url, fetchOptions)
19
33
 
20
34
  if (nullWhen404 && response.status === 404) {
@@ -22,20 +36,22 @@ const fetchJson = async (url, fetchOptions, nullWhen404) => {
22
36
  }
23
37
 
24
38
  if (!response.ok) {
25
- throw new Error(
26
- `${url} returned ${response.status}: ${
27
- response.statusText || 'Unknown Status Text'
28
- }. Body: ${await getTextFromResponse(response)}`
29
- )
39
+ const error = new Error(httpErrorMessage)
40
+ error.code = `${response.status}`
41
+ throw error
30
42
  }
31
43
 
32
- return response.json()
44
+ try {
45
+ return await response.json()
46
+ } catch (err) {
47
+ throw new Error(INSIGHT_JSON_ERROR_MESSAGE, { cause: err })
48
+ }
33
49
  }
34
50
 
35
- async function fetchJsonRetry(url, fetchOptions) {
51
+ async function fetchJsonRetry(url, fetchOptions, httpErrorMessage) {
36
52
  const waitTimes = ['5s', '10s', '20s', '30s']
37
53
  const fetchWithRetry = retry(fetchJson, { delayTimesMs: waitTimes })
38
- return fetchWithRetry(url, fetchOptions)
54
+ return fetchWithRetry(url, fetchOptions, false, httpErrorMessage)
39
55
  }
40
56
 
41
57
  export default class InsightAPIClient {
@@ -56,12 +72,17 @@ export default class InsightAPIClient {
56
72
  async fetchBalance(address) {
57
73
  const encodedAddress = encodeURIComponent(address)
58
74
  const url = urlJoin(this._baseURL, `/balance/${encodedAddress}`)
59
- return fetchJson(url)
75
+ return fetchJson(url, undefined, false, INSIGHT_HTTP_ERROR_BALANCE_MESSAGE)
60
76
  }
61
77
 
62
78
  async fetchBlockHeight() {
63
79
  const url = urlJoin(this._baseURL, '/status')
64
- const status = await fetchJson(url, { timeout: 10_000 })
80
+ const status = await fetchJson(
81
+ url,
82
+ { timeout: 10_000 },
83
+ false,
84
+ INSIGHT_HTTP_ERROR_STATUS_MESSAGE
85
+ )
65
86
  return status.info.blocks
66
87
  }
67
88
 
@@ -71,7 +92,7 @@ export default class InsightAPIClient {
71
92
  this._baseURL,
72
93
  opts?.includeTxs ? `/addr/${encodedAddress}` : `/addr/${encodedAddress}?noTxList=1`
73
94
  )
74
- return fetchJson(url)
95
+ return fetchJson(url, undefined, false, INSIGHT_HTTP_ERROR_ADDR_MESSAGE)
75
96
  }
76
97
 
77
98
  async fetchUTXOs(addresses, { assetNames = [] } = {}) {
@@ -83,7 +104,7 @@ export default class InsightAPIClient {
83
104
 
84
105
  const encodedAddresses = encodeURIComponent(addresses)
85
106
  const url = urlJoin(this._baseURL, `/addrs/${encodedAddresses}/utxo?${query}`)
86
- const utxos = await fetchJson(url)
107
+ const utxos = await fetchJson(url, undefined, false, INSIGHT_HTTP_ERROR_UTXO_MESSAGE)
87
108
 
88
109
  return utxos.map((utxo) => ({
89
110
  address: utxo.address,
@@ -100,12 +121,12 @@ export default class InsightAPIClient {
100
121
  async fetchTx(txId) {
101
122
  const encodedTxId = encodeURIComponent(txId)
102
123
  const url = urlJoin(this._baseURL, `/tx/${encodedTxId}`)
103
- return fetchJson(url, undefined, true)
124
+ return fetchJson(url, undefined, true, INSIGHT_HTTP_ERROR_TX_MESSAGE)
104
125
  }
105
126
 
106
127
  async fetchTxObject(txId) {
107
128
  const url = urlJoin(this._baseURL, `/fulltx?${new URLSearchParams({ hash: txId })}`)
108
- const object = await fetchJson(url, undefined, true)
129
+ const object = await fetchJson(url, undefined, true, INSIGHT_HTTP_ERROR_FULLTX_MESSAGE)
109
130
  if (!object || isEmpty(object)) {
110
131
  return null
111
132
  }
@@ -116,7 +137,7 @@ export default class InsightAPIClient {
116
137
  async fetchRawTx(txId) {
117
138
  const encodedTxId = encodeURIComponent(txId)
118
139
  const url = urlJoin(this._baseURL, `/rawtx/${encodedTxId}`)
119
- const { rawtx } = await fetchJson(url)
140
+ const { rawtx } = await fetchJson(url, undefined, false, INSIGHT_HTTP_ERROR_RAWTX_MESSAGE)
120
141
  return rawtx
121
142
  }
122
143
 
@@ -136,7 +157,7 @@ export default class InsightAPIClient {
136
157
  timeout: 10_000,
137
158
  }
138
159
 
139
- return fetchJsonRetry(url, fetchOptions)
160
+ return fetchJsonRetry(url, fetchOptions, INSIGHT_HTTP_ERROR_ADDRS_TXS_MESSAGE)
140
161
  }
141
162
 
142
163
  async fetchAllTxData(addrs = [], chunk = 25, httpDelay = 2000, shouldStopFetching = () => {}) {
@@ -159,12 +180,12 @@ export default class InsightAPIClient {
159
180
  async fetchUnconfirmedAncestorData(txId) {
160
181
  const encodedTxId = encodeURIComponent(txId)
161
182
  const url = urlJoin(this._baseURL, `/unconfirmed_ancestor/${encodedTxId}`)
162
- return fetchJson(url)
183
+ return fetchJson(url, undefined, false, INSIGHT_HTTP_ERROR_UNCONFIRMED_ANCESTOR_MESSAGE)
163
184
  }
164
185
 
165
186
  async fetchFeeRate() {
166
187
  const url = urlJoin(this._baseURL, '/v2/fees')
167
- return fetchJson(url)
188
+ return fetchJson(url, undefined, false, INSIGHT_HTTP_ERROR_FEES_MESSAGE)
168
189
  }
169
190
 
170
191
  async broadcastTx(rawTx) {
@@ -187,28 +208,32 @@ export default class InsightAPIClient {
187
208
  console.warn('Insight Broadcast HTTP Error:')
188
209
  console.warn(response.statusText)
189
210
  console.warn(data)
190
- throw new Error(`Insight Broadcast HTTP Error: ${data}`)
211
+ const error = new Error(INSIGHT_HTTP_ERROR_BROADCAST_MESSAGE)
212
+ error.code = `${response.status}`
213
+ throw error
191
214
  }
192
215
 
193
216
  try {
194
217
  data = JSON.parse(data)
195
218
  } catch (err) {
196
219
  console.warn('Insight Broadcast JSON Parse Error:', err.message, data)
197
- throw new Error(`data: ${data}`, { cause: err })
220
+ throw new Error(INSIGHT_JSON_ERROR_MESSAGE, { cause: err })
198
221
  }
199
222
 
200
- if (!data.txid) throw new Error('transaction id was not returned')
223
+ if (!data.txid) {
224
+ throw new Error(INSIGHT_MISSING_TXID_MESSAGE)
225
+ }
201
226
  }
202
227
 
203
228
  async getClaimable(address) {
204
229
  const encodedAddress = encodeURIComponent(address)
205
230
  const url = urlJoin(this._baseURL, `/addr/${encodedAddress}/claimable`)
206
- return fetchJson(url)
231
+ return fetchJson(url, undefined, false, INSIGHT_HTTP_ERROR_CLAIMABLE_MESSAGE)
207
232
  }
208
233
 
209
234
  async getUnclaimed(address) {
210
235
  const encodedAddress = encodeURIComponent(address)
211
236
  const url = urlJoin(this._baseURL, `/addr/${encodedAddress}/unclaimed`)
212
- return fetchJson(url)
237
+ return fetchJson(url, undefined, false, INSIGHT_HTTP_ERROR_UNCLAIMED_MESSAGE)
213
238
  }
214
239
  }
package/src/move-funds.js CHANGED
@@ -106,13 +106,13 @@ export const moveFundsFactory = ({
106
106
  assetName,
107
107
  walletAccount,
108
108
  })
109
- const recieveAddressesObjects = await assetClientInterface.getReceiveAddresses({
109
+ const receiveAddressesObjects = await assetClientInterface.getReceiveAddresses({
110
110
  walletAccount,
111
111
  assetName,
112
112
  multiAddressMode: config?.multiAddressMode ?? true,
113
113
  })
114
114
 
115
- const receiveAddresses = recieveAddressesObjects.map(
115
+ const receiveAddresses = receiveAddressesObjects.map(
116
116
  (receiveAddress) =>
117
117
  address.toLegacyAddress?.(receiveAddress.toString()) || receiveAddress.toString()
118
118
  )
@@ -6,6 +6,15 @@ import { extractTransactionContext } from '../psbt-parser.js'
6
6
  import { broadcastTransaction } from './broadcast-tx.js'
7
7
  import { updateAccountState, updateTransactionLog } from './update-state.js'
8
8
 
9
+ const checkTxExists = async ({ asset, txId }) => {
10
+ try {
11
+ const tx = await asset.insightClient.fetchTx(txId)
12
+ return Boolean(tx)
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
9
18
  const getSize = (tx) => {
10
19
  if (typeof tx.size === 'number') return tx.size
11
20
  if (typeof tx.virtualSize === 'function') {
@@ -199,15 +208,20 @@ export const sendTxFactory = ({
199
208
  try {
200
209
  await broadcastTransaction({ asset, rawTx })
201
210
  } catch (err) {
202
- if (/insight broadcast http error.*missing inputs/i.test(err.message)) {
203
- err.txInfo = JSON.stringify({
204
- amount: sendAmount.toDefaultString({ unit: true }),
205
- fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(),
206
- allUtxos: usableUtxos.toJSON(),
207
- })
208
- }
211
+ const txExists = await checkTxExists({ asset, txId })
212
+ if (txExists) {
213
+ console.warn(`tx-send: ${assetName} tx already broadcast`, txId)
214
+ } else {
215
+ if (/insight broadcast http error.*missing inputs/i.test(err.message)) {
216
+ err.txInfo = JSON.stringify({
217
+ amount: sendAmount.toDefaultString({ unit: true }),
218
+ fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(),
219
+ allUtxos: usableUtxos.toJSON(),
220
+ })
221
+ }
209
222
 
210
- throw err
223
+ throw err
224
+ }
211
225
  }
212
226
 
213
227
  const changeUtxoIndex = changeOutputIndex ?? -1