@exodus/bitcoin-api 4.14.6 → 4.15.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,36 @@
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.15.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.14.7...@exodus/bitcoin-api@4.15.0) (2026-05-18)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: add init time mempool WS URL support (#8045)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+
18
+ * fix(bitcoin-api): reduce dogecoin change dust to 1M koinu (#8080)
19
+
20
+ * fix: repair stale spent UTXOs after broadcast failure (#8087)
21
+
22
+ * fix: split mempool reads from clarity broadcast (#8094)
23
+
24
+
25
+
26
+ ## [4.14.7](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.14.6...@exodus/bitcoin-api@4.14.7) (2026-05-11)
27
+
28
+
29
+ ### Bug Fixes
30
+
31
+
32
+ * fix(bitcoin-api): respect custom overrides in createBtcLikeKeys (#7921)
33
+
34
+
35
+
6
36
  ## [4.14.6](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.14.5...@exodus/bitcoin-api@4.14.6) (2026-04-24)
7
37
 
8
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.14.6",
3
+ "version": "4.15.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",
@@ -63,5 +63,5 @@
63
63
  "type": "git",
64
64
  "url": "git+https://github.com/ExodusMovement/assets.git"
65
65
  },
66
- "gitHead": "2cecf114ca8d8516413cf6e8b654d239e4373c96"
66
+ "gitHead": "266396ec25f5aa6ce87887d0df7f0e882e83f1aa"
67
67
  }
@@ -48,8 +48,10 @@ export const createBtcLikeKeys = ({
48
48
  return encodePublicPurpose44(publicKey)
49
49
  })
50
50
  const encodePublicBech32 =
51
- encodePublicBech32Custom || versions.bech32 !== undefined
52
- ? (publicKey) => {
51
+ encodePublicBech32Custom ||
52
+ (versions.bech32 === undefined
53
+ ? undefined
54
+ : (publicKey) => {
53
55
  const pubKeyHash = hash160(publicKey)
54
56
  const witnessVersion = Buffer.from([0])
55
57
  const witnessProgram = Buffer.concat([
@@ -57,10 +59,10 @@ export const createBtcLikeKeys = ({
57
59
  Buffer.from(bech32.toWords(pubKeyHash)),
58
60
  ])
59
61
  return bech32.encode(versions.bech32, witnessProgram)
60
- }
61
- : undefined
62
+ })
62
63
  const encodePublicBech32FromWIF =
63
- encodePublicBech32FromWIFCustom || encodePublicBech32
64
+ encodePublicBech32FromWIFCustom ||
65
+ (encodePublicBech32
64
66
  ? (privateKeyWIF) => {
65
67
  // NOTE: No password support here
66
68
  const { versions } = coinInfo
@@ -68,10 +70,11 @@ export const createBtcLikeKeys = ({
68
70
  const publicKey = secp256k1.privateKeyToPublicKey({ privateKey, compressed })
69
71
  return encodePublicBech32(publicKey)
70
72
  }
71
- : undefined
73
+ : undefined)
72
74
 
73
75
  const encodeNestedP2WPKH =
74
- encodeNestedP2WPKHCustom || bitcoinjsLib
76
+ encodeNestedP2WPKHCustom ||
77
+ (bitcoinjsLib
75
78
  ? (publicKey) => {
76
79
  const pubkey = secp256k1.publicKeyConvert({
77
80
  publicKey,
@@ -88,7 +91,7 @@ export const createBtcLikeKeys = ({
88
91
  coinInfo.versions.scripthash
89
92
  )
90
93
  }
91
- : undefined
94
+ : undefined)
92
95
 
93
96
  const encodePublicTaproot =
94
97
  encodePublicTaprootCustom ||
package/src/dust.js CHANGED
@@ -7,7 +7,14 @@ const CHANGE_DUST_VALUES = {
7
7
  bgold: 6000,
8
8
  litecoin: 60_000,
9
9
  dash: 5500,
10
- dogecoin: 99_999_999,
10
+ // Dogecoin's network min-relay fee is high (~100 sat/byte), so the economic dust
11
+ // floor is ~55k sats for a P2PKH output. We keep a healthy margin (1 DOGE = 100M
12
+ // sats here is way too high — it caused legitimate sub-1-DOGE change to be folded
13
+ // into the fee, occasionally pushing the effective fee rate past the 200k sat/vB
14
+ // PSBT cap in `tx-sign/maximum-fee-rates.js` and blocking the send). 1M sats
15
+ // (~0.01 DOGE) keeps change outputs economically meaningful while leaving plenty
16
+ // of headroom below the max-fee-rate cap.
17
+ dogecoin: 1_000_000,
11
18
  decred: 70_000,
12
19
  digibyte: 60_000,
13
20
  zcash: 1500,
package/src/index.js CHANGED
@@ -15,7 +15,6 @@ export * from './fee/index.js'
15
15
  export * from './utxos-utils.js'
16
16
  export * from './tx-log/index.js'
17
17
  export * from './unconfirmed-ancestor-data.js'
18
- export * from './parse-unsigned-tx.js'
19
18
  export { getCreateBatchTransaction } from './tx-send/batch-tx.js'
20
19
  export { createPsbtToUnsignedTx } from './psbt-utils.js'
21
20
  export { createTxFactory } from './tx-create/create-tx.js'
@@ -209,32 +209,32 @@ const normalizeTx = (tx, tipHeight, outspends) => {
209
209
 
210
210
  export default class MempoolRestClient {
211
211
  constructor({
212
- baseURL,
213
- insightBaseURL,
212
+ mempoolBaseURL,
213
+ clarityBaseURL,
214
214
  retryWaitTimes = RETRY_WAIT_TIMES,
215
215
  configMinFeeRate = 10,
216
216
  } = {}) {
217
- this._baseURL = baseURL
218
- this._insightBaseURL = insightBaseURL
217
+ this._mempoolBaseURL = mempoolBaseURL
218
+ this._clarityBaseURL = clarityBaseURL
219
219
  this._retryWaitTimes = retryWaitTimes
220
220
  this._configMinFeeRate = configMinFeeRate
221
221
  }
222
222
 
223
- setBaseUrl(baseURL) {
224
- this._baseURL = baseURL
223
+ setMempoolBaseUrl(mempoolBaseURL) {
224
+ this._mempoolBaseURL = mempoolBaseURL
225
225
  }
226
226
 
227
- setInsightBaseUrl(baseURL) {
228
- this._insightBaseURL = baseURL
227
+ setClarityBaseUrl(clarityBaseURL) {
228
+ this._clarityBaseURL = clarityBaseURL
229
229
  }
230
230
 
231
- _apiUrl(path, { version } = {}) {
232
- return urlJoin(this._baseURL, version === 'v1' ? '/api/v1' : '/api', path)
231
+ _mempoolApiUrl(path, { version } = {}) {
232
+ return urlJoin(this._mempoolBaseURL, version === 'v1' ? '/api/v1' : '/api', path)
233
233
  }
234
234
 
235
235
  async _fetchTipHeight() {
236
236
  const tipText = await fetchText(
237
- this._apiUrl('/blocks/tip/height'),
237
+ this._mempoolApiUrl('/blocks/tip/height'),
238
238
  { timeout: 10_000 },
239
239
  { httpErrorMessage: MEMPOOL_HTTP_ERROR_STATUS_MESSAGE }
240
240
  )
@@ -248,10 +248,14 @@ export default class MempoolRestClient {
248
248
 
249
249
  async _fetchTxOutspends(txid) {
250
250
  const encodedTxid = encodeURIComponent(txid)
251
- const outspends = await fetchJson(this._apiUrl(`/tx/${encodedTxid}/outspends`), undefined, {
252
- nullWhen404: true,
253
- httpErrorMessage: MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE,
254
- })
251
+ const outspends = await fetchJson(
252
+ this._mempoolApiUrl(`/tx/${encodedTxid}/outspends`),
253
+ undefined,
254
+ {
255
+ nullWhen404: true,
256
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE,
257
+ }
258
+ )
255
259
  return Array.isArray(outspends) ? outspends : []
256
260
  }
257
261
 
@@ -260,7 +264,7 @@ export default class MempoolRestClient {
260
264
  const result = await withRetry(
261
265
  fetchJson,
262
266
  [
263
- this._apiUrl(`/txs/outspends?${query}`),
267
+ this._mempoolApiUrl(`/txs/outspends?${query}`),
264
268
  undefined,
265
269
  { nullWhen404: true, httpErrorMessage: MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE },
266
270
  ],
@@ -290,7 +294,7 @@ export default class MempoolRestClient {
290
294
 
291
295
  async _fetchPrevouts(outpoints) {
292
296
  return fetchJson(
293
- this._apiUrl('/prevouts', { version: 'v1' }),
297
+ this._mempoolApiUrl('/prevouts', { version: 'v1' }),
294
298
  {
295
299
  method: 'post',
296
300
  headers: {
@@ -320,7 +324,7 @@ export default class MempoolRestClient {
320
324
  const page = await withRetry(
321
325
  fetchJson,
322
326
  [
323
- this._apiUrl(path),
327
+ this._mempoolApiUrl(path),
324
328
  {
325
329
  method: 'post',
326
330
  headers: {
@@ -343,9 +347,13 @@ export default class MempoolRestClient {
343
347
 
344
348
  async fetchBalance(address) {
345
349
  const encodedAddress = encodeURIComponent(address)
346
- const utxos = await fetchJson(this._apiUrl(`/address/${encodedAddress}/utxo`), undefined, {
347
- httpErrorMessage: MEMPOOL_HTTP_ERROR_BALANCE_MESSAGE,
348
- })
350
+ const utxos = await fetchJson(
351
+ this._mempoolApiUrl(`/address/${encodedAddress}/utxo`),
352
+ undefined,
353
+ {
354
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_BALANCE_MESSAGE,
355
+ }
356
+ )
349
357
  const values = Array.isArray(utxos) ? utxos : []
350
358
  const totalSats = values.reduce((sum, utxo) => sum + Number(utxo?.value || 0), 0)
351
359
  return {
@@ -361,7 +369,7 @@ export default class MempoolRestClient {
361
369
  async fetchTx(txid) {
362
370
  const tipHeightPromise = this._fetchTipHeight()
363
371
  const encodedTxid = encodeURIComponent(txid)
364
- const tx = await fetchJson(this._apiUrl(`/tx/${encodedTxid}`), undefined, {
372
+ const tx = await fetchJson(this._mempoolApiUrl(`/tx/${encodedTxid}`), undefined, {
365
373
  nullWhen404: true,
366
374
  httpErrorMessage: MEMPOOL_HTTP_ERROR_TX_MESSAGE,
367
375
  })
@@ -380,7 +388,7 @@ export default class MempoolRestClient {
380
388
 
381
389
  async fetchRawTx(txid) {
382
390
  const encodedTxid = encodeURIComponent(txid)
383
- const rawTx = await fetchText(this._apiUrl(`/tx/${encodedTxid}/hex`), undefined, {
391
+ const rawTx = await fetchText(this._mempoolApiUrl(`/tx/${encodedTxid}/hex`), undefined, {
384
392
  httpErrorMessage: MEMPOOL_HTTP_ERROR_RAWTX_MESSAGE,
385
393
  })
386
394
  return String(rawTx).trim()
@@ -499,9 +507,13 @@ export default class MempoolRestClient {
499
507
 
500
508
  async fetchUTXOs(address) {
501
509
  const encodedAddress = encodeURIComponent(address)
502
- const utxos = await fetchJson(this._apiUrl(`/address/${encodedAddress}/utxo`), undefined, {
503
- httpErrorMessage: MEMPOOL_HTTP_ERROR_UTXO_MESSAGE,
504
- })
510
+ const utxos = await fetchJson(
511
+ this._mempoolApiUrl(`/address/${encodedAddress}/utxo`),
512
+ undefined,
513
+ {
514
+ httpErrorMessage: MEMPOOL_HTTP_ERROR_UTXO_MESSAGE,
515
+ }
516
+ )
505
517
 
506
518
  if (!Array.isArray(utxos) || utxos.length === 0) return []
507
519
  const tipHeightPromise = this._fetchTipHeight()
@@ -536,10 +548,10 @@ export default class MempoolRestClient {
536
548
 
537
549
  async fetchFeeRate() {
538
550
  const [mempoolFeeRate, blocks] = await Promise.all([
539
- fetchJson(this._apiUrl('/fees/recommended', { version: 'v1' }), undefined, {
551
+ fetchJson(this._mempoolApiUrl('/fees/recommended', { version: 'v1' }), undefined, {
540
552
  httpErrorMessage: MEMPOOL_HTTP_ERROR_FEES_MESSAGE,
541
553
  }),
542
- fetchJson(this._apiUrl('/fees/mempool-blocks', { version: 'v1' }), undefined, {
554
+ fetchJson(this._mempoolApiUrl('/fees/mempool-blocks', { version: 'v1' }), undefined, {
543
555
  httpErrorMessage: MEMPOOL_HTTP_ERROR_FEES_MESSAGE,
544
556
  }),
545
557
  ])
@@ -582,7 +594,7 @@ export default class MempoolRestClient {
582
594
  async fetchUnconfirmedAncestorData(txid) {
583
595
  const encodedTxid = encodeURIComponent(txid)
584
596
  const data = await fetchJson(
585
- this._apiUrl(`/cpfp/${encodedTxid}`, { version: 'v1' }),
597
+ this._mempoolApiUrl(`/cpfp/${encodedTxid}`, { version: 'v1' }),
586
598
  undefined,
587
599
  {
588
600
  httpErrorMessage: MEMPOOL_HTTP_ERROR_CPFP_MESSAGE,
@@ -598,7 +610,7 @@ export default class MempoolRestClient {
598
610
  return sum + Math.ceil(ancestor.weight / 4)
599
611
  }, 0)
600
612
  const selfFees = isFiniteNumber(data?.fee) ? data.fee : 0
601
- const tx = await fetchJson(this._apiUrl(`/tx/${encodedTxid}`), undefined, {
613
+ const tx = await fetchJson(this._mempoolApiUrl(`/tx/${encodedTxid}`), undefined, {
602
614
  nullWhen404: true,
603
615
  httpErrorMessage: MEMPOOL_HTTP_ERROR_TX_MESSAGE,
604
616
  })
@@ -616,7 +628,7 @@ export default class MempoolRestClient {
616
628
  // is temporary. The mempool REST API alone is not a drop-in replacement for that.
617
629
  async broadcastTx(rawTx) {
618
630
  const payload = rawTx instanceof Uint8Array ? Buffer.from(rawTx).toString('hex') : rawTx
619
- const response = await fetch(urlJoin(this._insightBaseURL, '/tx/send'), {
631
+ const response = await fetch(urlJoin(this._clarityBaseURL, '/tx/send'), {
620
632
  method: 'post',
621
633
  headers: {
622
634
  Accept: 'application/json',
@@ -118,9 +118,10 @@ export function toMempoolWebSocketUrl(apiUrl) {
118
118
  export function normalizeInsightConfig(config, monitorType) {
119
119
  const apiUrl = config?.insightServers?.[0]
120
120
  const mempoolApiUrl = config?.mempoolServer
121
+ const clarityApiUrl = config?.clarityServer
121
122
  const wsUrl =
122
123
  monitorType === 'mempool'
123
124
  ? config?.mempoolServerWS || toMempoolWebSocketUrl(mempoolApiUrl)
124
125
  : config?.insightServersWS?.[0] || toWSUrl(apiUrl)
125
- return { apiUrl, mempoolApiUrl, wsUrl }
126
+ return { apiUrl, mempoolApiUrl, clarityApiUrl, wsUrl }
126
127
  }
@@ -252,6 +252,11 @@ export class BitcoinMonitorScanner {
252
252
  }
253
253
  })
254
254
 
255
+ // Scan each receive/change chain from the known unused index plus the gap limit.
256
+ // Example: if purpose 84 has chain [3, 2] and gapLimit is 5, first scan
257
+ // receive indexes 0..8 and change indexes 0..7. If a tx pays receive index 6,
258
+ // newChains moves receive to 7. The second loop then scans receive 8..12
259
+ // and change 7..7. This keeps expanding until the fetched address batch has no txs.
255
260
  let allTxs = []
256
261
  for (let fetchCount = 0; ; fetchCount++) {
257
262
  const chainObjects = gapSearchParameters.flatMap(
@@ -591,8 +596,11 @@ export class BitcoinMonitorScanner {
591
596
  let utxoCol = UtxoCollection.fromArray(utxos, { currency })
592
597
 
593
598
  const utxosToRemoveCol = UtxoCollection.fromArray(utxosToRemove, { currency })
594
- // Keep new utxos when they intersect with the stored utxos.
595
- utxoCol = utxoCol.union(storedUtxos).difference(utxosToRemoveCol)
599
+ const spentStoredUtxos = storedUtxos.filter((utxo) => vinTxids[`${utxo.txId}-${utxo.vout}`])
600
+ // utxosToRemove only contains inputs we matched to our wallet addresses or outputs we exclude.
601
+ // spentStoredUtxos prevents stale stored UTXOs from surviving when their outpoint was spent,
602
+ // but vin.addr did not match our wallet address map.
603
+ utxoCol = utxoCol.union(storedUtxos).difference(utxosToRemoveCol).difference(spentStoredUtxos)
596
604
 
597
605
  const pendingDropCandidates = Object.values(unconfirmedTxsToCheck)
598
606
  const verificationResults = await Promise.all(
@@ -26,6 +26,7 @@ export class Monitor extends BaseMonitor {
26
26
  #ws
27
27
  #apiUrl
28
28
  #mempoolApiUrl
29
+ #mempoolWsUrl
29
30
  #monitorType
30
31
  #maxTrackedAddresses
31
32
  #wsUrl
@@ -45,6 +46,7 @@ export class Monitor extends BaseMonitor {
45
46
  insightClient,
46
47
  apiUrl,
47
48
  mempoolApiUrl,
49
+ mempoolWsUrl,
48
50
  monitorType,
49
51
  maxTrackedAddresses,
50
52
  scanner,
@@ -53,12 +55,12 @@ export class Monitor extends BaseMonitor {
53
55
  }) {
54
56
  super({ asset, interval, assetClientInterface, logger, runner })
55
57
  assert(insightClient, 'insightClient is required!')
56
- assert(apiUrl, 'apiUrl is required')
57
58
  assert(typeof yieldToUI === 'function', 'yieldToUI must be a function')
58
59
  this.#wsInterval = wsInterval
59
60
  this.#ws = null
60
61
  this.#apiUrl = apiUrl
61
62
  this.#mempoolApiUrl = mempoolApiUrl
63
+ this.#mempoolWsUrl = mempoolWsUrl
62
64
  this.#monitorType = monitorType
63
65
  this.#maxTrackedAddresses = maxTrackedAddresses
64
66
  this.#yieldToUI = yieldToUI
@@ -89,15 +91,20 @@ export class Monitor extends BaseMonitor {
89
91
  }
90
92
 
91
93
  setServer(assetConfig = {}) {
92
- const { apiUrl, mempoolApiUrl, wsUrl } = normalizeInsightConfig(assetConfig, this.#monitorType)
94
+ const { apiUrl, mempoolApiUrl, clarityApiUrl, wsUrl } = normalizeInsightConfig(
95
+ assetConfig,
96
+ this.#monitorType
97
+ )
93
98
 
94
99
  if (this.#monitorType === 'mempool') {
100
+ const clarityBaseUrl = clarityApiUrl || apiUrl
101
+
95
102
  if (mempoolApiUrl) {
96
- this.#insightClient.setBaseUrl(mempoolApiUrl)
103
+ this.#insightClient.setMempoolBaseUrl(mempoolApiUrl)
97
104
  }
98
105
 
99
- if (apiUrl) {
100
- this.#insightClient.setInsightBaseUrl(apiUrl)
106
+ if (clarityBaseUrl) {
107
+ this.#insightClient.setClarityBaseUrl(clarityBaseUrl)
101
108
  }
102
109
  } else if (apiUrl) {
103
110
  this.#insightClient.setBaseUrl(apiUrl)
@@ -108,7 +115,7 @@ export class Monitor extends BaseMonitor {
108
115
  wsUrl ||
109
116
  this.#wsUrl ||
110
117
  (this.#monitorType === 'mempool'
111
- ? toMempoolWebSocketUrl(this.#mempoolApiUrl)
118
+ ? this.#mempoolWsUrl || toMempoolWebSocketUrl(this.#mempoolApiUrl)
112
119
  : toWSUrl(this.#apiUrl))
113
120
  )
114
121
  }
@@ -4,6 +4,7 @@ import assert from 'minimalistic-assert'
4
4
 
5
5
  import { extractTransactionContext } from '../psbt-parser.js'
6
6
  import { broadcastTransaction } from './broadcast-tx.js'
7
+ import { repairConfirmedSpentInputs } from './repair-spent-inputs.js'
7
8
  import { updateAccountState, updateTransactionLog } from './update-state.js'
8
9
 
9
10
  const getErrorText = (error) => [error?.message, error?.reason].filter(Boolean).join(' ')
@@ -215,9 +216,27 @@ export const sendTxFactory = ({
215
216
  console.warn(`tx-send: ${assetName} tx already broadcast`, txId)
216
217
  } else {
217
218
  if (/(missing inputs|missingorspent)/i.test(getErrorText(err))) {
219
+ let prunedUtxos
220
+ try {
221
+ // Self-custody wallets can end up with phantom UTXOs after missed scans,
222
+ // restores, external spends, or backend gaps. If broadcast proves a selected
223
+ // input is already spent, repair local state, but only after confirming the
224
+ // spending tx so the same stale UTXO is not selected again.
225
+ prunedUtxos = await repairConfirmedSpentInputs({
226
+ asset,
227
+ assetClientInterface,
228
+ assetName,
229
+ walletAccount,
230
+ selectedUtxos,
231
+ })
232
+ } catch {
233
+ // Keep the original broadcast error. Repair is best-effort.
234
+ }
235
+
218
236
  err.txInfo = JSON.stringify({
219
237
  amount: sendAmount.toDefaultString({ unit: true }),
220
238
  fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(),
239
+ prunedUtxos: prunedUtxos?.toJSON(),
221
240
  allUtxos: usableUtxos.toJSON(),
222
241
  })
223
242
  }
@@ -245,7 +264,6 @@ export const sendTxFactory = ({
245
264
  txId,
246
265
  changeUtxoIndex,
247
266
  script,
248
- usableUtxos,
249
267
  selectedUtxos,
250
268
  replaceTx,
251
269
  changeAmount,
@@ -0,0 +1,99 @@
1
+ import { UtxoCollection } from '@exodus/models'
2
+
3
+ import { getUtxos } from '../utxos-utils.js'
4
+
5
+ const MIN_SPENT_INPUT_CONFIRMATIONS = 6
6
+ const REPAIR_SPENT_INPUTS_ASSETS = new Set(['bitcoin', 'bitcointestnet', 'bitcoinregtest'])
7
+
8
+ const getOutputByVout = ({ tx, vout }) => {
9
+ if (!Array.isArray(tx?.vout)) return
10
+
11
+ return tx.vout.find((output) => output.n === vout)
12
+ }
13
+
14
+ const fetchTxObjectsById = async ({ insightClient, txIds }) => {
15
+ const txEntries = await Promise.all(
16
+ [...txIds].map(async (txId) => {
17
+ try {
18
+ return [txId, (await insightClient.fetchTxObject(txId)) || null]
19
+ } catch {
20
+ return [txId, null]
21
+ }
22
+ })
23
+ )
24
+
25
+ return new Map(txEntries)
26
+ }
27
+
28
+ export async function getConfirmedSpentSelectedUtxos({
29
+ asset,
30
+ selectedUtxos,
31
+ minConfirmations = MIN_SPENT_INPUT_CONFIRMATIONS,
32
+ }) {
33
+ const insightClient = asset.insightClient
34
+ if (
35
+ !REPAIR_SPENT_INPUTS_ASSETS.has(asset.name) ||
36
+ typeof insightClient.fetchTxObject !== 'function' ||
37
+ selectedUtxos.size === 0
38
+ ) {
39
+ return UtxoCollection.createEmpty({ currency: asset.currency })
40
+ }
41
+
42
+ const selectedUtxosArray = selectedUtxos.toArray()
43
+
44
+ const parentTxsById = await fetchTxObjectsById({
45
+ insightClient,
46
+ txIds: new Set(selectedUtxosArray.map((utxo) => utxo.txId)),
47
+ })
48
+
49
+ const selectedUtxosWithSpentTxIds = selectedUtxosArray
50
+ .map((utxo) => {
51
+ const output = getOutputByVout({ tx: parentTxsById.get(utxo.txId), vout: utxo.vout })
52
+ return { utxo, spentTxId: output?.spentTxId }
53
+ })
54
+ .filter(({ spentTxId }) => spentTxId)
55
+
56
+ const spendingTxsById = await fetchTxObjectsById({
57
+ insightClient,
58
+ txIds: new Set(selectedUtxosWithSpentTxIds.map(({ spentTxId }) => spentTxId)),
59
+ })
60
+
61
+ const spentUtxos = selectedUtxosWithSpentTxIds
62
+ .filter(
63
+ ({ spentTxId }) => (spendingTxsById.get(spentTxId)?.confirmations || 0) >= minConfirmations
64
+ )
65
+ .map(({ utxo }) => utxo)
66
+
67
+ return UtxoCollection.fromArray(spentUtxos, { currency: asset.currency })
68
+ }
69
+
70
+ export async function repairConfirmedSpentInputs({
71
+ asset,
72
+ assetClientInterface,
73
+ assetName,
74
+ walletAccount,
75
+ selectedUtxos,
76
+ }) {
77
+ const spentSelectedUtxos = await getConfirmedSpentSelectedUtxos({ asset, selectedUtxos })
78
+
79
+ if (spentSelectedUtxos.size === 0) return spentSelectedUtxos
80
+
81
+ const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
82
+
83
+ const spentUtxoIds = new Set(
84
+ spentSelectedUtxos.toArray().map((utxo) => `${utxo.txId}:${utxo.vout}`.toLowerCase())
85
+ )
86
+
87
+ await assetClientInterface.updateAccountState({
88
+ assetName,
89
+ walletAccount,
90
+ newData: {
91
+ utxos: getUtxos({ accountState, asset }).difference(spentSelectedUtxos),
92
+ knownBalanceUtxoIds: accountState.knownBalanceUtxoIds?.filter(
93
+ (id) => !spentUtxoIds.has(id.toLowerCase())
94
+ ),
95
+ },
96
+ })
97
+
98
+ return spentSelectedUtxos
99
+ }
@@ -113,16 +113,16 @@ export async function updateAccountState({
113
113
  txId,
114
114
  changeUtxoIndex,
115
115
  script,
116
- usableUtxos,
117
116
  selectedUtxos,
118
117
  replaceTx,
119
118
  changeAmount,
120
119
  ourAddress,
121
120
  rbfEnabled,
122
121
  }) {
123
- // Update remaining UTXOs
124
122
  const knownBalanceUtxoIds = accountState.knownBalanceUtxoIds || []
125
- let remainingUtxos = usableUtxos.difference(selectedUtxos)
123
+ // updateAccountState replaces the stored UTXO collection, so remove selected inputs
124
+ // from the full stored set.
125
+ let remainingUtxos = accountState.utxos.difference(selectedUtxos)
126
126
 
127
127
  // Add change UTXO if present
128
128
  if (changeUtxoIndex !== -1 && ourAddress) {
@@ -1,96 +0,0 @@
1
- import { Transaction as BitcoinTransactionClass } from '@exodus/bitcoinjs'
2
- import BIPPath from 'bip32-path'
3
- import lodash from 'lodash'
4
- import assert from 'minimalistic-assert'
5
-
6
- export const parseUnsignedTxFactory =
7
- ({ Transaction = BitcoinTransactionClass } = {}) =>
8
- async ({ asset, unsignedTx, getAddress }) => {
9
- const assetName = asset.name
10
-
11
- const toNumberUnit = (value) => {
12
- const parsed = Buffer.isBuffer(value) ? BigInt('0x' + value.reverse().toString('hex')) : value // handle Dogecoin buffer values
13
- return asset.currency.baseUnit(parsed)
14
- }
15
-
16
- const parseAddress = assetName === 'bcash' ? asset.address.toCashAddress : (address) => address
17
-
18
- const { inputs, outputs } = unsignedTx.txData
19
-
20
- const from = lodash.uniq(inputs.map(({ address }) => parseAddress(address)))
21
- const outputAddresses = lodash.uniq(outputs.map(([address]) => address))
22
- const inputTxIds = lodash.uniq(inputs.map(({ txId }) => txId))
23
- const insightClient = asset.baseAsset.insightClient
24
- const inputTxsRaw = await Promise.all(inputTxIds.map((txId) => insightClient.fetchRawTx(txId)))
25
- const inputTxsById = new Map(
26
- inputTxsRaw.map((hex) => {
27
- const tx = Transaction.fromHex(hex)
28
- return [tx.getId(), tx]
29
- })
30
- )
31
-
32
- inputs.forEach(({ txId, vout, value }) => {
33
- const tx = inputTxsById.get(txId)
34
- const output = tx.outs[vout]
35
- const expected = output.value
36
- assert(
37
- lodash.isEqual(expected, value),
38
- `${txId} tx input has invalid value. Expected: ${expected} , Actual: ${value}`
39
- )
40
- })
41
-
42
- const { addressPathsMap } = unsignedTx.txMeta
43
- const [changeOutputAddresses, toOutputAddresses] = lodash.partition(
44
- outputAddresses,
45
- (address) => {
46
- const path = addressPathsMap[address]
47
- if (!path) return false
48
-
49
- const [chain, index] = BIPPath.fromString(path).path
50
- const isOurs = getAddress({ chain, index }).toString() === address
51
- return isOurs && chain === 1
52
- }
53
- )
54
-
55
- // the user is explicitly sending to a change address, so take a wild guess
56
- if (toOutputAddresses.length === 0) {
57
- toOutputAddresses.push(changeOutputAddresses.pop())
58
- }
59
-
60
- const [changeOutputs, sendOutputs] = lodash.partition(outputs, ([address]) =>
61
- changeOutputAddresses.includes(address)
62
- )
63
-
64
- const sum = (arr) => arr.reduce((a, b) => a.add(b), asset.currency.ZERO)
65
- const sumOutputs = (outputs) => sum(outputs.map(([_, value]) => toNumberUnit(value)))
66
-
67
- // sum(inputs) = intendedSendAmount + fee + change
68
- // sum(outputs) = intendedSendAmount + change
69
-
70
- const inputsNF = inputs.map(({ value }) => toNumberUnit(value))
71
- const outputsNF = outputs.map(([_, value]) => toNumberUnit(value))
72
- const changeAmount = sumOutputs(changeOutputs)
73
- const amount = sumOutputs(sendOutputs)
74
-
75
- const inputsTotal = sum(inputsNF)
76
- const outputsTotal = sum(outputsNF)
77
- const fee = inputsTotal.sub(outputsTotal)
78
-
79
- const changeAddress =
80
- changeOutputAddresses.length > 0 ? parseAddress(changeOutputAddresses[0]) : null
81
- // TODO: return entire toOutputAddresses array
82
- assert(toOutputAddresses.length === 1, 'multiple outputs not supported yet in 2fa mode')
83
-
84
- const to = parseAddress(toOutputAddresses[0])
85
- return {
86
- asset,
87
- from,
88
- to,
89
- amount,
90
- changeAddress,
91
- changeAmount,
92
- fee,
93
- }
94
- }
95
-
96
- export const parseUnsignedTx = parseUnsignedTxFactory()