@exodus/bitcoin-api 4.12.0 → 4.13.0

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,16 @@
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.13.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.12.0...@exodus/bitcoin-api@4.13.0) (2026-03-20)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat(bitcoin-api): add mempool rest client with insight parity (#7615)
13
+
14
+
15
+
6
16
  ## [4.12.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.11.1...@exodus/bitcoin-api@4.12.0) (2026-03-20)
7
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.12.0",
3
+ "version": "4.13.0",
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",
@@ -32,7 +32,7 @@
32
32
  "@exodus/currency": "^6.0.1",
33
33
  "@exodus/i18n-dummy": "^1.0.0",
34
34
  "@exodus/key-identifier": "^1.3.0",
35
- "@exodus/models": "^12.0.1",
35
+ "@exodus/models": "^13.0.0",
36
36
  "@exodus/safe-string": "^1.4.0",
37
37
  "@exodus/send-validation-model": "^1.0.0",
38
38
  "@exodus/simple-retry": "^0.0.6",
@@ -63,5 +63,5 @@
63
63
  "type": "git",
64
64
  "url": "git+https://github.com/ExodusMovement/assets.git"
65
65
  },
66
- "gitHead": "f1f7c3ad9d55f774c9c6a09434139d46f605e43c"
66
+ "gitHead": "49d472ae5c602ba68329227e0e0cd3a9b4568fba"
67
67
  }
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ export * from './balances.js'
3
3
  export * from './btc-address.js'
4
4
  export * from './btc-like-address.js'
5
5
  export * from './btc-like-keys.js'
6
+ export { default as MempoolRestClient } from './insight-api-client/mempool-rest-client.js'
6
7
  export { default as MempoolWSClient } from './insight-api-client/mempool-ws-client.js'
7
8
  export { default as InsightAPIClient } from './insight-api-client/index.js'
8
9
  export { default as InsightWSClient } from './insight-api-client/ws.js'
@@ -0,0 +1,660 @@
1
+ import { safeString } from '@exodus/safe-string'
2
+ import { retry } from '@exodus/simple-retry'
3
+ import { TraceId } from '@exodus/traceparent'
4
+ import delay from 'delay'
5
+ import urlJoin from 'url-join'
6
+
7
+ const API_PAGE_SIZE = 25
8
+ const DEFAULT_PAGE_SIZE = 10
9
+ const RETRY_WAIT_TIMES = ['5s', '10s', '20s', '30s']
10
+
11
+ const MEMPOOL_HTTP_ERROR_MESSAGE = safeString`mempool-api-http-error`
12
+ const MEMPOOL_INSIGHT_JSON_ERROR_MESSAGE = safeString`mempool-insight-api-invalid-json`
13
+ const MEMPOOL_INSIGHT_MISSING_TXID_MESSAGE = safeString`mempool-insight-api-missing-txid`
14
+ const MEMPOOL_INSIGHT_HTTP_ERROR_BROADCAST_MESSAGE = safeString`mempool-insight-api-http-error:broadcast`
15
+ const MEMPOOL_JSON_ERROR_MESSAGE = safeString`mempool-api-invalid-json`
16
+ const MEMPOOL_TEXT_ERROR_MESSAGE = safeString`mempool-api-invalid-text`
17
+ const MEMPOOL_HTTP_ERROR_TX_MESSAGE = safeString`mempool-api-http-error:tx`
18
+ const MEMPOOL_HTTP_ERROR_RAWTX_MESSAGE = safeString`mempool-api-http-error:rawtx`
19
+ const MEMPOOL_HTTP_ERROR_BALANCE_MESSAGE = safeString`mempool-api-http-error:balance`
20
+ const MEMPOOL_HTTP_ERROR_ADDR_TXS_MESSAGE = safeString`mempool-api-http-error:address-txs`
21
+ const MEMPOOL_HTTP_ERROR_UTXO_MESSAGE = safeString`mempool-api-http-error:utxo`
22
+ const MEMPOOL_HTTP_ERROR_PREVOUTS_MESSAGE = safeString`mempool-api-http-error:prevouts`
23
+ const MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE = safeString`mempool-api-http-error:outspends`
24
+ const MEMPOOL_HTTP_ERROR_STATUS_MESSAGE = safeString`mempool-api-http-error:status`
25
+ const MEMPOOL_HTTP_ERROR_FEES_MESSAGE = safeString`mempool-api-http-error:fees`
26
+ const MEMPOOL_HTTP_ERROR_CPFP_MESSAGE = safeString`mempool-api-http-error:cpfp`
27
+ const MEMPOOL_FEES_PAYLOAD_ERROR_MESSAGE = safeString`mempool-api-invalid-fees-payload`
28
+ const MEMPOOL_INVALID_VALUE_ERROR_MESSAGE = safeString`mempool-api-invalid-value`
29
+
30
+ const parseBroadcastErrorReason = (data) => {
31
+ if (!data) {
32
+ return null
33
+ }
34
+
35
+ try {
36
+ const parsed = JSON.parse(data)
37
+ return parsed?.error || data
38
+ } catch {
39
+ return data
40
+ }
41
+ }
42
+
43
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value)
44
+
45
+ const toBTCStringFromSatsString = (satsString) => {
46
+ let value = String(satsString)
47
+ while (value.length <= 8) value = `0${value}`
48
+ return `${value.slice(0, -8)}.${value.slice(-8)}`
49
+ }
50
+
51
+ const toBTCString = (sats) => {
52
+ if (isFiniteNumber(sats)) {
53
+ return toBTCStringFromSatsString(Math.trunc(sats))
54
+ }
55
+
56
+ if (typeof sats === 'string' || typeof sats === 'bigint') {
57
+ return toBTCStringFromSatsString(sats)
58
+ }
59
+
60
+ throw new Error(MEMPOOL_INVALID_VALUE_ERROR_MESSAGE)
61
+ }
62
+
63
+ const toBTC = (sats) => {
64
+ return Number.parseFloat(toBTCString(sats))
65
+ }
66
+
67
+ const normalizeTxConfirmations = (status, tipHeight) => {
68
+ const isConfirmed = !!status?.confirmed
69
+ const blockHeight = status?.block_height
70
+ if (!isConfirmed || !Number.isInteger(blockHeight) || !Number.isInteger(tipHeight)) {
71
+ return 0
72
+ }
73
+
74
+ return Math.max(0, tipHeight - blockHeight + 1)
75
+ }
76
+
77
+ const getNextBlockMinimumFee = (blocks) => {
78
+ if (!Array.isArray(blocks) || blocks.length === 0) {
79
+ throw new Error(MEMPOOL_FEES_PAYLOAD_ERROR_MESSAGE)
80
+ }
81
+
82
+ const firstBlock = blocks[0]
83
+ const feeRange = firstBlock && Array.isArray(firstBlock.feeRange) ? firstBlock.feeRange : null
84
+ if (!feeRange || feeRange.length === 0) {
85
+ throw new Error(MEMPOOL_FEES_PAYLOAD_ERROR_MESSAGE)
86
+ }
87
+
88
+ return Math.ceil(feeRange[0])
89
+ }
90
+
91
+ const getMinimumFee = ({ minimumFee, hourFee, configMinFeeRate, nextBlockMinimumFee }) => {
92
+ return Math.min(Math.max(minimumFee, configMinFeeRate), Math.min(hourFee, nextBlockMinimumFee))
93
+ }
94
+
95
+ const createHttpError = (response, message) => {
96
+ const error = new Error(message)
97
+ const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
98
+ if (traceId) {
99
+ error.traceId = traceId
100
+ }
101
+
102
+ error.code = `${response.status}`
103
+ return error
104
+ }
105
+
106
+ async function withRetry(fn, args = [], { waitTimes = RETRY_WAIT_TIMES } = {}) {
107
+ const fetchWithRetry = retry(fn, { delayTimesMs: waitTimes })
108
+ return fetchWithRetry(...args)
109
+ }
110
+
111
+ async function fetchJson(url, fetchOptions, { nullWhen404 = false, httpErrorMessage } = {}) {
112
+ const response = await fetch(url, fetchOptions)
113
+ if (nullWhen404 && response.status === 404) {
114
+ return null
115
+ }
116
+
117
+ if (!response.ok) {
118
+ throw createHttpError(response, httpErrorMessage || MEMPOOL_HTTP_ERROR_MESSAGE)
119
+ }
120
+
121
+ try {
122
+ return await response.json()
123
+ } catch (err) {
124
+ const error = new Error(MEMPOOL_JSON_ERROR_MESSAGE, { cause: err })
125
+ const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
126
+ if (traceId) {
127
+ error.traceId = traceId
128
+ }
129
+
130
+ throw error
131
+ }
132
+ }
133
+
134
+ async function fetchText(url, fetchOptions, { nullWhen404 = false, httpErrorMessage } = {}) {
135
+ const response = await fetch(url, fetchOptions)
136
+ if (nullWhen404 && response.status === 404) {
137
+ return null
138
+ }
139
+
140
+ if (!response.ok) {
141
+ throw createHttpError(response, httpErrorMessage || MEMPOOL_HTTP_ERROR_MESSAGE)
142
+ }
143
+
144
+ try {
145
+ return await response.text()
146
+ } catch (err) {
147
+ const error = new Error(MEMPOOL_TEXT_ERROR_MESSAGE, { cause: err })
148
+ const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
149
+ if (traceId) {
150
+ error.traceId = traceId
151
+ }
152
+
153
+ throw error
154
+ }
155
+ }
156
+
157
+ const normalizeVin = (vin) => {
158
+ if (!vin || vin.is_coinbase) return {}
159
+ return {
160
+ txid: vin.txid,
161
+ vout: vin.vout,
162
+ addr: vin.prevout?.scriptpubkey_address,
163
+ value: toBTCString(vin.prevout?.value),
164
+ }
165
+ }
166
+
167
+ const normalizeVout = (vout, index, outspends) => {
168
+ const outspend = Array.isArray(outspends) ? outspends[index] : undefined
169
+ const spentTxId =
170
+ outspend?.spent && typeof outspend?.txid === 'string' ? outspend.txid : undefined
171
+ const spentIndex = outspend?.spent && typeof outspend?.vin === 'number' ? outspend.vin : undefined
172
+ return {
173
+ n: Number.isInteger(vout?.n) ? vout.n : Number.isInteger(vout?.vout) ? vout.vout : index,
174
+ value: toBTCString(vout?.value),
175
+ spentTxId,
176
+ spentIndex,
177
+ scriptPubKey: {
178
+ hex: vout?.scriptpubkey,
179
+ addresses: vout?.scriptpubkey_address ? [vout.scriptpubkey_address] : [],
180
+ },
181
+ }
182
+ }
183
+
184
+ const normalizeTx = (tx, tipHeight, outspends) => {
185
+ const normalizedVin = Array.isArray(tx?.vin) ? tx.vin.map((vin) => normalizeVin(vin)) : []
186
+ const normalizedVout = Array.isArray(tx?.vout)
187
+ ? tx.vout.map((vout, index) => normalizeVout(vout, index, outspends))
188
+ : []
189
+ const vsize = Number.isInteger(tx?.weight) ? Math.ceil(tx.weight / 4) : undefined
190
+ const rbf =
191
+ Array.isArray(tx?.vin) &&
192
+ tx.vin.some((vin) => Number.isInteger(vin?.sequence) && vin.sequence < 0xff_ff_ff_fe)
193
+
194
+ return {
195
+ ...tx,
196
+ txid: tx?.txid,
197
+ vin: normalizedVin,
198
+ vout: normalizedVout,
199
+ time: tx?.status?.block_time,
200
+ blockheight: tx?.status?.block_height ?? -1,
201
+ fees: toBTC(tx?.fee),
202
+ vsize,
203
+ rbf,
204
+ confirmations: normalizeTxConfirmations(tx?.status, tipHeight),
205
+ }
206
+ }
207
+
208
+ export default class MempoolRestClient {
209
+ constructor({
210
+ baseURL,
211
+ insightBaseURL,
212
+ retryWaitTimes = RETRY_WAIT_TIMES,
213
+ configMinFeeRate = 0,
214
+ } = {}) {
215
+ this._baseURL = baseURL
216
+ this._insightBaseURL = insightBaseURL
217
+ this._retryWaitTimes = retryWaitTimes
218
+ this._configMinFeeRate = configMinFeeRate
219
+ }
220
+
221
+ setBaseUrl(baseURL) {
222
+ this._baseURL = baseURL
223
+ }
224
+
225
+ setInsightBaseUrl(baseURL) {
226
+ this._insightBaseURL = baseURL
227
+ }
228
+
229
+ _apiUrl(path, { version } = {}) {
230
+ return urlJoin(this._baseURL, version === 'v1' ? '/api/v1' : '/api', path)
231
+ }
232
+
233
+ async _fetchTipHeight() {
234
+ const tipText = await fetchText(
235
+ this._apiUrl('/blocks/tip/height'),
236
+ { timeout: 10_000 },
237
+ { httpErrorMessage: MEMPOOL_HTTP_ERROR_STATUS_MESSAGE }
238
+ )
239
+ const tipHeight = Number.parseInt(tipText, 10)
240
+ if (!Number.isInteger(tipHeight) || tipHeight < 0) {
241
+ throw new Error(MEMPOOL_TEXT_ERROR_MESSAGE)
242
+ }
243
+
244
+ return tipHeight
245
+ }
246
+
247
+ async _fetchTxOutspends(txid) {
248
+ const encodedTxid = encodeURIComponent(txid)
249
+ const outspends = await fetchJson(this._apiUrl(`/tx/${encodedTxid}/outspends`), undefined, {
250
+ nullWhen404: true,
251
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE,
252
+ })
253
+ return Array.isArray(outspends) ? outspends : []
254
+ }
255
+
256
+ async _fetchTxOutspendsBatch(txids) {
257
+ const query = new URLSearchParams({ txids: txids.join(',') }).toString()
258
+ const result = await withRetry(
259
+ fetchJson,
260
+ [
261
+ this._apiUrl(`/txs/outspends?${query}`),
262
+ undefined,
263
+ { nullWhen404: true, httpErrorMessage: MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE },
264
+ ],
265
+ { waitTimes: this._retryWaitTimes }
266
+ )
267
+
268
+ return Array.isArray(result) ? result : []
269
+ }
270
+
271
+ async _fetchOutspendsByTxids(txids) {
272
+ const txidList = Array.isArray(txids) ? txids : []
273
+ const outspendsByTxid = new Map()
274
+ if (txidList.length === 0) return outspendsByTxid
275
+
276
+ const BATCH_SIZE = 50
277
+ for (let i = 0; i < txidList.length; i += BATCH_SIZE) {
278
+ const batchTxids = txidList.slice(i, i + BATCH_SIZE)
279
+ const batched = await this._fetchTxOutspendsBatch(batchTxids)
280
+
281
+ for (const [j, batchTxid] of batchTxids.entries()) {
282
+ outspendsByTxid.set(batchTxid, Array.isArray(batched[j]) ? batched[j] : [])
283
+ }
284
+ }
285
+
286
+ return outspendsByTxid
287
+ }
288
+
289
+ async _fetchPrevouts(outpoints) {
290
+ return fetchJson(
291
+ this._apiUrl('/prevouts', { version: 'v1' }),
292
+ {
293
+ method: 'post',
294
+ headers: {
295
+ Accept: 'application/json',
296
+ 'Content-Type': 'application/json',
297
+ },
298
+ body: JSON.stringify(outpoints),
299
+ },
300
+ {
301
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_PREVOUTS_MESSAGE,
302
+ }
303
+ )
304
+ }
305
+
306
+ async _fetchMultiAddressTxPage(addresses, { afterTxId, maxTxs }) {
307
+ const query = new URLSearchParams()
308
+ if (afterTxId) {
309
+ query.set('after_txid', afterTxId)
310
+ }
311
+
312
+ if (Number.isInteger(maxTxs) && maxTxs > 0) {
313
+ query.set('max_txs', `${maxTxs}`)
314
+ }
315
+
316
+ const queryString = query.toString()
317
+ const path = '/addresses/txs' + (queryString ? `?${queryString}` : '')
318
+ const page = await withRetry(
319
+ fetchJson,
320
+ [
321
+ this._apiUrl(path),
322
+ {
323
+ method: 'post',
324
+ headers: {
325
+ Accept: 'application/json',
326
+ 'Content-Type': 'application/json',
327
+ },
328
+ body: JSON.stringify(addresses),
329
+ },
330
+ { httpErrorMessage: MEMPOOL_HTTP_ERROR_ADDR_TXS_MESSAGE },
331
+ ],
332
+ { waitTimes: this._retryWaitTimes }
333
+ )
334
+ const items = Array.isArray(page) ? page : []
335
+ const nextAfterTxId = items[items.length - 1]?.txid
336
+ const effectivePageSize = Number.isInteger(maxTxs) && maxTxs > 0 ? maxTxs : API_PAGE_SIZE
337
+ const hasMore = !!nextAfterTxId && items.length >= effectivePageSize
338
+
339
+ return { items, nextAfterTxId, hasMore }
340
+ }
341
+
342
+ async fetchBalance(address) {
343
+ const encodedAddress = encodeURIComponent(address)
344
+ const utxos = await fetchJson(this._apiUrl(`/address/${encodedAddress}/utxo`), undefined, {
345
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_BALANCE_MESSAGE,
346
+ })
347
+ const values = Array.isArray(utxos) ? utxos : []
348
+ const totalSats = values.reduce((sum, utxo) => sum + Number(utxo?.value || 0), 0)
349
+ return {
350
+ utxoCount: values.length,
351
+ balance: toBTC(totalSats),
352
+ }
353
+ }
354
+
355
+ async fetchBlockHeight() {
356
+ return this._fetchTipHeight()
357
+ }
358
+
359
+ async fetchTx(txid) {
360
+ const tipHeightPromise = this._fetchTipHeight()
361
+ const encodedTxid = encodeURIComponent(txid)
362
+ const tx = await fetchJson(this._apiUrl(`/tx/${encodedTxid}`), undefined, {
363
+ nullWhen404: true,
364
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_TX_MESSAGE,
365
+ })
366
+
367
+ if (!tx) return null
368
+
369
+ const outspends =
370
+ Array.isArray(tx?.vout) && tx.vout.length > 0 ? await this._fetchTxOutspends(txid) : undefined
371
+ const tipHeight = await tipHeightPromise
372
+ return normalizeTx(tx, tipHeight, outspends)
373
+ }
374
+
375
+ async fetchTxObject(txid) {
376
+ return this.fetchTx(txid)
377
+ }
378
+
379
+ async fetchRawTx(txid) {
380
+ const encodedTxid = encodeURIComponent(txid)
381
+ const rawTx = await fetchText(this._apiUrl(`/tx/${encodedTxid}/hex`), undefined, {
382
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_RAWTX_MESSAGE,
383
+ })
384
+ return String(rawTx).trim()
385
+ }
386
+
387
+ async fetchTxData(addresses, requestOpts = {}) {
388
+ if (!Array.isArray(addresses) || addresses.length === 0) {
389
+ return { items: [] }
390
+ }
391
+
392
+ const from = Number.isInteger(requestOpts.from) ? Math.max(0, requestOpts.from) : 0
393
+ const to = Number.isInteger(requestOpts.to)
394
+ ? Math.max(from, requestOpts.to)
395
+ : Math.max(from + DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE)
396
+
397
+ const maxTxs = Math.max(API_PAGE_SIZE, to - from)
398
+ const shouldIncludeSpent = requestOpts?.noSpent !== 1
399
+ let afterTxId
400
+ const dedupedTxs = new Map()
401
+ const tipHeightPromise = this._fetchTipHeight()
402
+
403
+ while (dedupedTxs.size < to) {
404
+ const previousCursor = afterTxId
405
+ const page = await this._fetchMultiAddressTxPage(addresses, { afterTxId, maxTxs })
406
+ afterTxId = page.nextAfterTxId
407
+ const txsNeedingOutspends = []
408
+
409
+ for (const tx of page.items) {
410
+ txsNeedingOutspends.push(tx)
411
+ }
412
+
413
+ const outspendsByTxid =
414
+ shouldIncludeSpent && txsNeedingOutspends.length > 0
415
+ ? await this._fetchOutspendsByTxids(
416
+ txsNeedingOutspends
417
+ .filter((tx) => Array.isArray(tx?.vout) && tx.vout.length > 0)
418
+ .map((tx) => tx.txid)
419
+ )
420
+ : new Map()
421
+
422
+ for (const tx of txsNeedingOutspends) {
423
+ const outspends = shouldIncludeSpent ? outspendsByTxid.get(tx.txid) : undefined
424
+ const tipHeight = await tipHeightPromise
425
+
426
+ dedupedTxs.delete(tx.txid)
427
+ dedupedTxs.set(tx.txid, normalizeTx(tx, tipHeight, outspends))
428
+ }
429
+
430
+ if (afterTxId === previousCursor || !page.hasMore) {
431
+ break
432
+ }
433
+ }
434
+
435
+ return {
436
+ items: [...dedupedTxs.values()].slice(from, to),
437
+ }
438
+ }
439
+
440
+ async fetchAllTxData(
441
+ addresses = [],
442
+ chunk = 25,
443
+ httpDelay = 2000,
444
+ shouldStopFetching = () => {}
445
+ ) {
446
+ if (!Array.isArray(addresses) || addresses.length === 0) {
447
+ return []
448
+ }
449
+
450
+ const dedupedTxs = new Map()
451
+ const tipHeightPromise = this._fetchTipHeight()
452
+ const maxTxs = Math.max(1, chunk)
453
+ let afterTxId
454
+
455
+ while (true) {
456
+ const txs = []
457
+ const previousCursor = afterTxId
458
+ const page = await this._fetchMultiAddressTxPage(addresses, { afterTxId, maxTxs })
459
+ afterTxId = page.nextAfterTxId
460
+ const pageTxs = []
461
+
462
+ for (const tx of page.items) {
463
+ pageTxs.push(tx)
464
+ }
465
+
466
+ const txidsNeedingOutspends = pageTxs
467
+ .filter((tx) => Array.isArray(tx?.vout) && tx.vout.length > 0)
468
+ .map((tx) => tx.txid)
469
+
470
+ const outspendsByTxid =
471
+ txidsNeedingOutspends.length > 0
472
+ ? await this._fetchOutspendsByTxids(txidsNeedingOutspends)
473
+ : new Map()
474
+
475
+ for (const tx of pageTxs) {
476
+ const outspends =
477
+ Array.isArray(tx?.vout) && tx.vout.length > 0 ? outspendsByTxid.get(tx.txid) : undefined
478
+ const tipHeight = await tipHeightPromise
479
+
480
+ const normalizedTx = normalizeTx(tx, tipHeight, outspends)
481
+ txs.push(normalizedTx)
482
+ dedupedTxs.delete(tx.txid)
483
+ dedupedTxs.set(tx.txid, normalizedTx)
484
+ }
485
+
486
+ if (afterTxId === previousCursor || !page.hasMore) {
487
+ break
488
+ }
489
+
490
+ if (txs.length > 0 && shouldStopFetching && (await shouldStopFetching(txs))) break
491
+
492
+ await delay(httpDelay)
493
+ }
494
+
495
+ return [...dedupedTxs.values()]
496
+ }
497
+
498
+ async fetchUTXOs(address) {
499
+ const encodedAddress = encodeURIComponent(address)
500
+ const utxos = await fetchJson(this._apiUrl(`/address/${encodedAddress}/utxo`), undefined, {
501
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_UTXO_MESSAGE,
502
+ })
503
+
504
+ if (!Array.isArray(utxos) || utxos.length === 0) return []
505
+ const tipHeightPromise = this._fetchTipHeight()
506
+ const outpoints = utxos.map((utxo) => ({ txid: utxo.txid, vout: utxo.vout }))
507
+
508
+ const scriptsByOutpoint = new Map()
509
+ for (let i = 0; i < outpoints.length; i += 100) {
510
+ const batch = outpoints.slice(i, i + 100)
511
+ const prevouts = await this._fetchPrevouts(batch)
512
+ if (!Array.isArray(prevouts)) continue
513
+
514
+ for (const [j, outpoint] of batch.entries()) {
515
+ const script = prevouts[j]?.prevout?.scriptpubkey
516
+ if (typeof script === 'string') {
517
+ scriptsByOutpoint.set(`${outpoint.txid}:${outpoint.vout}`, script)
518
+ }
519
+ }
520
+ }
521
+
522
+ const tipHeight = await tipHeightPromise
523
+
524
+ return utxos.map((utxo) => ({
525
+ address,
526
+ txId: utxo.txid,
527
+ confirmations: normalizeTxConfirmations(utxo.status, tipHeight),
528
+ value: toBTC(utxo.value),
529
+ vout: utxo.vout,
530
+ height: utxo.status?.block_height ?? null,
531
+ script: scriptsByOutpoint.get(`${utxo.txid}:${utxo.vout}`),
532
+ }))
533
+ }
534
+
535
+ async fetchFeeRate() {
536
+ const [mempoolFeeRate, blocks] = await Promise.all([
537
+ fetchJson(this._apiUrl('/fees/recommended', { version: 'v1' }), undefined, {
538
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_FEES_MESSAGE,
539
+ }),
540
+ fetchJson(this._apiUrl('/fees/mempool-blocks', { version: 'v1' }), undefined, {
541
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_FEES_MESSAGE,
542
+ }),
543
+ ])
544
+ const fastestFee = mempoolFeeRate?.fastestFee
545
+ const halfHourFee = mempoolFeeRate?.halfHourFee
546
+ const hourFee = mempoolFeeRate?.hourFee
547
+ const economyFee = mempoolFeeRate?.economyFee
548
+ const baseMinimumFee = mempoolFeeRate?.minimumFee
549
+ const nextBlockMinimumFee = getNextBlockMinimumFee(blocks)
550
+
551
+ const minimumFee = getMinimumFee({
552
+ minimumFee: baseMinimumFee,
553
+ hourFee,
554
+ configMinFeeRate: this._configMinFeeRate,
555
+ nextBlockMinimumFee,
556
+ })
557
+
558
+ if (
559
+ !isFiniteNumber(fastestFee) ||
560
+ !isFiniteNumber(halfHourFee) ||
561
+ !isFiniteNumber(hourFee) ||
562
+ !isFiniteNumber(economyFee) ||
563
+ !isFiniteNumber(baseMinimumFee) ||
564
+ !isFiniteNumber(nextBlockMinimumFee) ||
565
+ !isFiniteNumber(minimumFee)
566
+ ) {
567
+ throw new Error(MEMPOOL_FEES_PAYLOAD_ERROR_MESSAGE)
568
+ }
569
+
570
+ return {
571
+ fastestFee,
572
+ halfHourFee,
573
+ hourFee,
574
+ minimumFee,
575
+ economyFee,
576
+ nextBlockMinimumFee,
577
+ }
578
+ }
579
+
580
+ async fetchUnconfirmedAncestorData(txid) {
581
+ const encodedTxid = encodeURIComponent(txid)
582
+ const data = await fetchJson(
583
+ this._apiUrl(`/cpfp/${encodedTxid}`, { version: 'v1' }),
584
+ undefined,
585
+ {
586
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_CPFP_MESSAGE,
587
+ }
588
+ )
589
+ const ancestors = Array.isArray(data?.ancestors) ? data.ancestors : []
590
+ const ancestorFees = ancestors.reduce((sum, ancestor) => {
591
+ if (!isFiniteNumber(ancestor?.fee)) return sum
592
+ return sum + ancestor.fee
593
+ }, 0)
594
+ const ancestorSize = ancestors.reduce((sum, ancestor) => {
595
+ if (!isFiniteNumber(ancestor?.weight)) return sum
596
+ return sum + Math.ceil(ancestor.weight / 4)
597
+ }, 0)
598
+ const selfFees = isFiniteNumber(data?.fee) ? data.fee : 0
599
+ const tx = await fetchJson(this._apiUrl(`/tx/${encodedTxid}`), undefined, {
600
+ nullWhen404: true,
601
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_TX_MESSAGE,
602
+ })
603
+ const selfSize = isFiniteNumber(tx?.weight) ? Math.ceil(tx.weight / 4) : 0
604
+
605
+ const fees = ancestorFees + selfFees
606
+ const size = ancestorSize + selfSize
607
+ if (fees <= 0 || size <= 0) return { size: 0, fees: 0 }
608
+ return { size, fees: Math.round(fees) }
609
+ }
610
+
611
+ // Keep broadcast behavior aligned with the existing insight client.
612
+ // Our Insight/Magnifier path fans out to Exodus-managed bitcoin nodes and has the
613
+ // operational behavior we already depend on when propagation is slow or a rejection
614
+ // is temporary. The mempool REST API alone is not a drop-in replacement for that.
615
+ async broadcastTx(rawTx) {
616
+ const payload = rawTx instanceof Uint8Array ? Buffer.from(rawTx).toString('hex') : rawTx
617
+ const response = await fetch(urlJoin(this._insightBaseURL, '/tx/send'), {
618
+ method: 'post',
619
+ headers: {
620
+ Accept: 'application/json',
621
+ 'Content-Type': 'application/json',
622
+ },
623
+ body: JSON.stringify({ rawtx: payload }),
624
+ })
625
+
626
+ let data = await response.text()
627
+
628
+ if (!response.ok) {
629
+ console.warn(`Mempool Client's Insight Broadcast HTTP Error:`)
630
+ console.warn(response.statusText)
631
+ console.warn(data)
632
+ const error = createHttpError(response, MEMPOOL_INSIGHT_HTTP_ERROR_BROADCAST_MESSAGE)
633
+ error.reason = parseBroadcastErrorReason(data)
634
+ throw error
635
+ }
636
+
637
+ try {
638
+ data = JSON.parse(data)
639
+ } catch (err) {
640
+ console.warn(`Mempool Client's Insight Broadcast JSON Parse Error:`, err.message, data)
641
+ const error = new Error(MEMPOOL_INSIGHT_JSON_ERROR_MESSAGE, { cause: err })
642
+ const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
643
+ if (traceId) {
644
+ error.traceId = traceId
645
+ }
646
+
647
+ throw error
648
+ }
649
+
650
+ if (!data.txid) {
651
+ const error = new Error(MEMPOOL_INSIGHT_MISSING_TXID_MESSAGE)
652
+ const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
653
+ if (traceId) {
654
+ error.traceId = traceId
655
+ }
656
+
657
+ throw error
658
+ }
659
+ }
660
+ }