@exodus/bitcoin-api 4.8.3 → 4.9.1

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.1](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.0...@exodus/bitcoin-api@4.9.1) (2026-01-26)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix(bitcoin-api): add safe-string errors for Insight failures (#7320)
13
+
14
+
15
+
16
+ ## [4.9.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.8.3...@exodus/bitcoin-api@4.9.0) (2025-12-23)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat: Expose utxo sendTx workflow to accept provided unsigned tx (#6890)
23
+
24
+
25
+
6
26
  ## [4.8.3](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.8.2...@exodus/bitcoin-api@4.8.3) (2025-12-16)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.8.3",
3
+ "version": "4.9.1",
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": "84be327e5d39ecfd0e36abc038f33f7f3b9c33b8"
64
+ "gitHead": "481b327c2b57d6272c47807615be9f967ebb3d13"
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 })
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ export * from './unconfirmed-ancestor-data.js'
16
16
  export * from './parse-unsigned-tx.js'
17
17
  export { getCreateBatchTransaction } from './tx-send/batch-tx.js'
18
18
  export { createPsbtToUnsignedTx } from './psbt-utils.js'
19
+ export { createTxFactory } from './tx-create/create-tx.js'
19
20
  export * from './insight-api-client/util.js'
20
21
  export * from './move-funds.js'
21
22
  export { createEncodeMultisigContract } from './multisig-address.js'
@@ -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
  }
@@ -318,7 +318,7 @@ function buildAddressPathsMap(selectedUtxos, changeOutputData) {
318
318
  function buildOutputAddressPurposesMap(outputs) {
319
319
  const map = Object.create(null)
320
320
  for (const output of outputs) {
321
- if (output.address?.meta?.purpose) {
321
+ if (output.address?.meta?.purpose && output.address?.meta?.path) {
322
322
  map[output.address.address] = output.address.meta.purpose
323
323
  }
324
324
  }
@@ -1,3 +1,4 @@
1
+ import { Psbt as DefaultPsbt, Transaction as DefaultTransaction } from '@exodus/bitcoinjs'
1
2
  import { Address, UtxoCollection } from '@exodus/models'
2
3
  import lodash from 'lodash'
3
4
  import assert from 'minimalistic-assert'
@@ -471,7 +472,8 @@ const transferHandler = {
471
472
  changeAddress: context.changeAddress,
472
473
  })
473
474
 
474
- // Create a map of wallet's own output addresses and their purposes.
475
+ // Track our own outputs and their purposes. Today this is only the change
476
+ // output; in the future we may add self-send primaries when applicable.
475
477
  const outputAddressPurposesMap = Object.create(null)
476
478
 
477
479
  // Add the keypath of change address to support Trezor detect the change output.
@@ -517,13 +519,14 @@ export const createTxFactory =
517
519
  assetClientInterface,
518
520
  changeAddressType,
519
521
  allowedPurposes,
520
- Psbt,
521
- Transaction,
522
+ Psbt = DefaultPsbt,
523
+ Transaction = DefaultTransaction,
522
524
  }) =>
523
525
  async ({
524
526
  asset,
525
527
  walletAccount,
526
528
  type,
529
+ address, // same as toAddress.
527
530
  toAddress,
528
531
  amount,
529
532
  multipleAddressesEnabled,
@@ -532,7 +535,7 @@ export const createTxFactory =
532
535
  isSendAll,
533
536
  bumpTxId,
534
537
  isExchange,
535
- isRbfAllowed,
538
+ isRbfAllowed = true,
536
539
  taprootInputWitnessSize,
537
540
  }) => {
538
541
  const assetName = asset.name
@@ -547,7 +550,7 @@ export const createTxFactory =
547
550
  return txHandler.buildTransaction({
548
551
  asset,
549
552
  walletAccount,
550
- toAddress,
553
+ toAddress: toAddress ?? address,
551
554
  amount,
552
555
  multipleAddressesEnabled,
553
556
  feePerKB,
@@ -3,7 +3,6 @@ import { Psbt as DefaultPsbt, Transaction as DefaultTransaction } from '@exodus/
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
5
  import { extractTransactionContext } from '../psbt-parser.js'
6
- import { createTxFactory } from '../tx-create/create-tx.js'
7
6
  import { broadcastTransaction } from './broadcast-tx.js'
8
7
  import { updateAccountState, updateTransactionLog } from './update-state.js'
9
8
 
@@ -99,46 +98,6 @@ export async function signTransaction({
99
98
  return { rawTx, txId, tx }
100
99
  }
101
100
 
102
- function getTransferUnsignedTx(txContext) {
103
- const {
104
- inputs,
105
- outputs,
106
- psbtBuffer,
107
- useCashAddress,
108
- addressPathsMap,
109
- outputAddressPurposesMap,
110
- blockHeight,
111
- rawTxs,
112
- txType,
113
- rbfEnabled,
114
- bumpTxId,
115
- changeOutputIndex,
116
- sendOutputIndexes,
117
- } = txContext
118
-
119
- return {
120
- unsignedTx: {
121
- txData: {
122
- inputs,
123
- outputs,
124
- psbtBuffer,
125
- },
126
- txMeta: {
127
- useCashAddress,
128
- addressPathsMap,
129
- outputAddressPurposesMap,
130
- blockHeight,
131
- rawTxs,
132
- txType,
133
- rbfEnabled,
134
- bumpTxId,
135
- changeOutputIndex,
136
- sendOutputIndexes,
137
- },
138
- },
139
- }
140
- }
141
-
142
101
  // Get additional transaction metadata that are not part of the unsignedTx.txMeta
143
102
  function getExtendedTxMeta(txContext) {
144
103
  const {
@@ -170,64 +129,40 @@ function getExtendedTxMeta(txContext) {
170
129
 
171
130
  // not ported from Exodus; but this demos signing / broadcasting
172
131
  // NOTE: this will be ripped out in the coming weeks
173
- export const createAndBroadcastTXFactory =
174
- ({
175
- getFeeEstimator,
176
- getSizeAndChangeScript = getSizeAndChangeScriptFactory(), // for decred customizations
177
- allowUnconfirmedRbfEnabledUtxos,
178
- utxosDescendingOrder,
179
- assetClientInterface,
180
- changeAddressType,
181
- allowedPurposes,
182
- Psbt = DefaultPsbt,
183
- Transaction = DefaultTransaction,
184
- }) =>
185
- async ({
186
- asset,
187
- walletAccount,
188
- address,
189
- amount,
190
- multipleAddressesEnabled,
191
- feePerKB,
192
- customFee,
193
- isSendAll,
194
- bumpTxId: bumpTxIdProvided,
195
- isExchange,
196
- isRbfAllowed = true,
197
- taprootInputWitnessSize,
198
- }) => {
132
+ export const sendTxFactory = ({
133
+ createTx,
134
+ getSizeAndChangeScript = getSizeAndChangeScriptFactory(), // for decred customizations
135
+ assetClientInterface,
136
+ allowedPurposes,
137
+ Psbt = DefaultPsbt,
138
+ Transaction = DefaultTransaction,
139
+ }) => {
140
+ assert(assetClientInterface, 'assetClientInterface is required')
141
+ assert(createTx, 'createTx is required')
142
+
143
+ return async ({ asset, walletAccount, unsignedTx: providedUnsignedTx, ...legacyParams }) => {
144
+ const { address, ...createTxParams } = legacyParams
199
145
  const assetName = asset.name
200
146
  const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
201
147
 
202
- const createTx = createTxFactory({
203
- getFeeEstimator,
204
- allowUnconfirmedRbfEnabledUtxos,
205
- utxosDescendingOrder,
206
- assetClientInterface,
207
- changeAddressType,
208
- allowedPurposes,
209
- Psbt,
210
- Transaction,
211
- })
148
+ const resolveUnsignedTx = async () => {
149
+ if (providedUnsignedTx) {
150
+ return { unsignedTx: providedUnsignedTx }
151
+ }
212
152
 
213
- const { unsignedTx: unsignedTxByCreateTx } = await createTx({
214
- asset,
215
- walletAccount,
216
- type: 'transfer',
217
- toAddress: address,
218
- amount,
219
- isRbfAllowed,
220
- multipleAddressesEnabled,
221
- feePerKB,
222
- customFee,
223
- isSendAll,
224
- bumpTxId: bumpTxIdProvided,
225
- isExchange,
226
- taprootInputWitnessSize,
227
- })
153
+ return createTx({
154
+ asset,
155
+ walletAccount,
156
+ type: 'transfer',
157
+ toAddress: address,
158
+ ...createTxParams,
159
+ })
160
+ }
161
+
162
+ const { unsignedTx } = await resolveUnsignedTx()
228
163
 
229
164
  const txContext = await extractTransactionContext({
230
- unsignedTx: unsignedTxByCreateTx,
165
+ unsignedTx,
231
166
  asset,
232
167
  assetClientInterface,
233
168
  walletAccount,
@@ -236,8 +171,6 @@ export const createAndBroadcastTXFactory =
236
171
  Transaction,
237
172
  })
238
173
 
239
- const { unsignedTx } = getTransferUnsignedTx(txContext)
240
-
241
174
  const {
242
175
  fee,
243
176
  sendAmount,
@@ -330,6 +263,7 @@ export const createAndBroadcastTXFactory =
330
263
  replacedTxId: replaceTx?.txId,
331
264
  }
332
265
  }
266
+ }
333
267
 
334
268
  // back compatibiliy
335
269
  export { getSendDustValue as getDustValue } from '../dust.js'