@exodus/bitcoin-api 4.14.7 → 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 +20 -0
- package/package.json +2 -2
- package/src/dust.js +8 -1
- package/src/insight-api-client/mempool-rest-client.js +43 -31
- package/src/insight-api-client/util.js +2 -1
- package/src/tx-log/bitcoin-monitor-scanner.js +10 -2
- package/src/tx-log/bitcoin-monitor.js +13 -6
- package/src/tx-send/index.js +19 -1
- package/src/tx-send/repair-spent-inputs.js +99 -0
- package/src/tx-send/update-state.js +3 -3
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.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
|
+
|
|
6
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)
|
|
7
27
|
|
|
8
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/bitcoin-api",
|
|
3
|
-
"version": "4.
|
|
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": "
|
|
66
|
+
"gitHead": "266396ec25f5aa6ce87887d0df7f0e882e83f1aa"
|
|
67
67
|
}
|
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
|
-
|
|
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,
|
|
@@ -209,32 +209,32 @@ const normalizeTx = (tx, tipHeight, outspends) => {
|
|
|
209
209
|
|
|
210
210
|
export default class MempoolRestClient {
|
|
211
211
|
constructor({
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
mempoolBaseURL,
|
|
213
|
+
clarityBaseURL,
|
|
214
214
|
retryWaitTimes = RETRY_WAIT_TIMES,
|
|
215
215
|
configMinFeeRate = 10,
|
|
216
216
|
} = {}) {
|
|
217
|
-
this.
|
|
218
|
-
this.
|
|
217
|
+
this._mempoolBaseURL = mempoolBaseURL
|
|
218
|
+
this._clarityBaseURL = clarityBaseURL
|
|
219
219
|
this._retryWaitTimes = retryWaitTimes
|
|
220
220
|
this._configMinFeeRate = configMinFeeRate
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
|
|
224
|
-
this.
|
|
223
|
+
setMempoolBaseUrl(mempoolBaseURL) {
|
|
224
|
+
this._mempoolBaseURL = mempoolBaseURL
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
this.
|
|
227
|
+
setClarityBaseUrl(clarityBaseURL) {
|
|
228
|
+
this._clarityBaseURL = clarityBaseURL
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
return urlJoin(this.
|
|
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.
|
|
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(
|
|
252
|
-
|
|
253
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
347
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
503
|
-
|
|
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.
|
|
551
|
+
fetchJson(this._mempoolApiUrl('/fees/recommended', { version: 'v1' }), undefined, {
|
|
540
552
|
httpErrorMessage: MEMPOOL_HTTP_ERROR_FEES_MESSAGE,
|
|
541
553
|
}),
|
|
542
|
-
fetchJson(this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
595
|
-
|
|
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(
|
|
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.
|
|
103
|
+
this.#insightClient.setMempoolBaseUrl(mempoolApiUrl)
|
|
97
104
|
}
|
|
98
105
|
|
|
99
|
-
if (
|
|
100
|
-
this.#insightClient.
|
|
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
|
}
|
package/src/tx-send/index.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|