@exodus/bitcoin-api 1.0.0-alpha.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.
@@ -0,0 +1,110 @@
1
+ import assert from 'minimalistic-assert'
2
+ import { groupBy, sortBy } from 'lodash'
3
+
4
+ // return first to last (ALSO FILTERS)
5
+ // we need to do this because Insight often returns the blocktime as time and many
6
+ // txs may fall inside the same block, but if one is an input to other, it should preceed
7
+ /*
8
+ A -> B (tx A is an input to tx B)
9
+ B -> C
10
+ A -> C
11
+
12
+ start:
13
+
14
+ C B A (same time, ordered)... look at C, check if C's inputs are ahead, look for one furthest, then set + 1
15
+
16
+ B A C check B, A is in input
17
+
18
+ A B C check A, no inputs
19
+ */
20
+ export function orderTxs(txs: Array) {
21
+ if (txs.length === 1) return [...txs]
22
+
23
+ // filter duplicates
24
+ const txMap = {}
25
+ txs.forEach((tx) => {
26
+ txMap[tx.txid] = tx
27
+ })
28
+
29
+ // group by time
30
+ const txTimesMap = groupBy(Object.values(txMap), (tx) => tx.time || '')
31
+
32
+ // sort unique time keys
33
+ const txTimes = Object.keys(txTimesMap)
34
+ txTimes.sort((a, b) => ~~a - ~~b)
35
+
36
+ let rettxs = []
37
+
38
+ txTimes.forEach((txTime) => {
39
+ let txsAtTime = txTimesMap[txTime] // array of txs
40
+ // should always be an array, but just incase
41
+ if (!Array.isArray(txsAtTime)) return
42
+ if (txsAtTime.length === 1) return rettxs.push(txsAtTime[0])
43
+
44
+ // existing order { txid: orderNum }
45
+ let txOrderCol = []
46
+ for (let i = 0; i < txsAtTime.length; ++i) {
47
+ txOrderCol.push({ txid: txsAtTime[i].txid, order: i })
48
+ }
49
+
50
+ // determine new orders, checks each vin and looks for max order,
51
+ // want current tx greater than any vin
52
+ const txsAtTimeClone = txsAtTime.slice(0)
53
+ while (txsAtTime.length > 0) {
54
+ let tx = txsAtTime.shift()
55
+ let maxOrder = 0
56
+ tx.vin.forEach((vin) => {
57
+ let txOrderMatch = txOrderCol.find((opair) => opair.txid === vin.txid)
58
+ if (txOrderMatch && txOrderMatch.order > maxOrder) maxOrder = txOrderMatch.order
59
+ })
60
+ txOrderCol.find((opair) => opair.txid === tx.txid).order = maxOrder + 1
61
+ }
62
+
63
+ let baseTime = ~~txTime
64
+ const newWorldOrder = sortBy(txOrderCol, ['order'])
65
+ newWorldOrder.forEach((opair, i) => {
66
+ let txid = opair.txid
67
+
68
+ let tx = txsAtTimeClone.find((tx) => tx.txid === txid)
69
+ assert(tx, 'InsightAPIClient.orderTxs() tx undefined.')
70
+
71
+ // we must do this to preserve order outside of this algorithm, difference is negligible
72
+ tx.time = txTime ? baseTime + i : baseTime
73
+ rettxs.push(tx)
74
+ })
75
+ })
76
+
77
+ return rettxs
78
+ }
79
+
80
+ /**
81
+ * It concerts an api URL to a WS service URL.
82
+ *
83
+ * https://somebtc.a.exodus.io/insight/ => https://somebtc.a.exodus.io
84
+ *
85
+ * @param {string} apiUrl the original apiUrl
86
+ * @returns {string} a WS url without the paths.
87
+ */
88
+ export function toWSUrl(apiUrl) {
89
+ if (!apiUrl) {
90
+ return apiUrl
91
+ }
92
+ try {
93
+ // Note, we are not using URL because URL is different between mobile, desktop and BE
94
+ // Using hydra's networking modules is an overkill for this function.
95
+ const firstSplit = apiUrl.split('://')
96
+ if (firstSplit.length > 1) {
97
+ return `${firstSplit[0]}://${firstSplit[1].split('/')[0]}`
98
+ } else {
99
+ return apiUrl
100
+ }
101
+ } catch (e) {
102
+ return apiUrl
103
+ }
104
+ }
105
+
106
+ export function normalizeInsightConfig(config) {
107
+ const apiUrl = config?.insightServers?.[0]
108
+ const wsUrl = config?.insightServersWS?.[0] || toWSUrl(apiUrl)
109
+ return { apiUrl, wsUrl }
110
+ }
@@ -0,0 +1,54 @@
1
+ import { EventEmitter } from 'events'
2
+ import io from 'socket.io-client'
3
+
4
+ export default class InsightWSClient extends EventEmitter {
5
+ constructor(url, assetName) {
6
+ super()
7
+ this.url = url
8
+ this.assetName = assetName
9
+ this.io = null
10
+ }
11
+
12
+ connect(addresses, opts) {
13
+ const options = Object.assign(
14
+ { transports: ['websocket'], reconnectionDelayMax: 30000, reconnectionDelay: 10000 },
15
+ opts
16
+ )
17
+
18
+ this.io = io(this.url, options)
19
+
20
+ this.io.on('connect', () => {
21
+ this.emit('connect')
22
+
23
+ addresses.forEach((address) => {
24
+ this.io.emit('subscribe', address)
25
+ })
26
+
27
+ if (['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(this.assetName)) {
28
+ this.io.emit('subscribe', 'inv_blocks')
29
+ }
30
+ })
31
+
32
+ addresses.forEach((address) => {
33
+ this.io.on(address, (data) => {
34
+ this.emit('message', { address, data })
35
+ })
36
+ })
37
+
38
+ this.io.on('block', (data) => {
39
+ this.emit('block', data)
40
+ })
41
+
42
+ this.io.on('disconnect', () => {
43
+ this.emit('disconnect')
44
+ })
45
+
46
+ this.io.on('reconnect', () => {
47
+ this.emit('reconnect')
48
+ })
49
+ }
50
+
51
+ close() {
52
+ if (this.io) this.io.disconnect()
53
+ }
54
+ }
@@ -0,0 +1,19 @@
1
+ import { buildBip32Path } from '@exodus/keychain'
2
+
3
+ export default ({ asset }) => ({ purpose, accountIndex, chainIndex, addressIndex }) => {
4
+ // TODO, move key-utils.js hydra/keychain to asset libs.
5
+ // https://github.com/ExodusMovement/assets/pull/410#discussion_r1026331735
6
+ const derivationPath = buildBip32Path({
7
+ asset: asset,
8
+ purpose,
9
+ accountIndex,
10
+ chainIndex,
11
+ addressIndex,
12
+ })
13
+
14
+ return {
15
+ derivationAlgorithm: 'BIP32',
16
+ derivationPath,
17
+ keyType: 'secp256k1',
18
+ }
19
+ }
@@ -0,0 +1,475 @@
1
+ /* @flow */
2
+ import { orderTxs } from '../insight-api-client/util'
3
+ import { Address, UtxoCollection } from '@exodus/models'
4
+ import { isEqual, compact } from 'lodash'
5
+ import { isRVNAssetScript } from '@exodus/ravencoin-lib'
6
+ import ms from 'ms'
7
+
8
+ import assert from 'minimalistic-assert'
9
+ import { getUtxos } from '../utxos-utils'
10
+
11
+ // Time to check whether to drop a sent tx
12
+ const SENT_TIME_TO_DROP = ms('2m')
13
+
14
+ function isReceiveAddress(addr: Address): boolean {
15
+ return parsePath(addr.meta.path)[0] === 0
16
+ }
17
+ function isChangeAddress(addr: Address): boolean {
18
+ return parsePath(addr.meta.path)[0] === 1
19
+ }
20
+
21
+ function parsePath(path) {
22
+ let p1 = path ? path.replace('m/', '').split('/') : ['0', '0']
23
+ p1 = p1.map((i) => parseInt(i, 10))
24
+ return p1
25
+ }
26
+
27
+ export async function rescanBlockchainInsight({
28
+ asset,
29
+ walletAccount,
30
+ refresh,
31
+ assetClientInterface,
32
+ insightClient,
33
+ yieldToUI,
34
+ isIos,
35
+ }) {
36
+ const assetName = asset.name
37
+ const purposes = await assetClientInterface.getSupportedPurposes({ assetName, walletAccount })
38
+ const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
39
+ const currency = asset.currency
40
+ const currentTxs = await assetClientInterface.getTxLog({ assetName, walletAccount })
41
+ const currentUtxos = getUtxos({ asset, accountState })
42
+
43
+ const currentTime = new Date().getTime()
44
+ const unconfirmedTxsToCheck = Array.from(currentTxs).reduce((txs, tx) => {
45
+ if (tx.pending) {
46
+ // Don't check sent tx that is younger than 2 minutes, because server might not have indexed it yet
47
+ if (!tx.sent || !tx.date || currentTime - tx.date.getTime() > SENT_TIME_TO_DROP) {
48
+ txs[tx.txId] = tx
49
+ }
50
+ }
51
+ return txs
52
+ }, {})
53
+
54
+ // this works because txs come back descending
55
+ // if we receive a batch that is completely in our txLog, we'll assume we reached old territory
56
+ // we also only consider confirmed txs because unconfirmed txs' order is not guaranteed
57
+ const shouldStop = refresh
58
+ ? () => false
59
+ : (txs) =>
60
+ txs.filter((tx) => {
61
+ const txItem = currentTxs.get(tx.txid)
62
+ return txItem && txItem.confirmed
63
+ }).length >= txs.length
64
+
65
+ const unusedAddressIndexes = await assetClientInterface.getUnusedAddressIndexes({
66
+ assetName,
67
+ walletAccount,
68
+ })
69
+ /*
70
+ * The chain fields/variables are number arrays of size 2
71
+ *
72
+ * The index of the array represents a chainIndex. Possible values are [0,1]
73
+ * A value of the array represents the largest unused addressIndex that chainIndex. Possible values are 0+
74
+ *
75
+ * The chains and newChains structures have the following format:
76
+ *
77
+ * [{ purpose: purposeNumber, chain: [receiveAddressIndexNumber, changeAddressIndexNumber] }, ...]
78
+ *
79
+ * - purpose is the available purpose for the given chain. BTC allows 44, 84, 89 atm.
80
+ * - chain is always an array of 2. Each value represent the higher unused addressIndex
81
+ * - in multi-address mode on, the wallet would show the highest unused addressIndex address
82
+ * - in multi-address mode off, the wallet would show the 0 addressIndex address
83
+ *
84
+ * Example:
85
+ * [ { purpose: 44, chain: [5, 7] } , { purpose: 84, chain: [1, 10] }, { purpose: 86, chain: [0, 0] } ]
86
+ *
87
+ * Means:
88
+ * - for bip/purpose 44 and chainIndex 0 (receive), 5 is the greatest unused addressIndex
89
+ * - for bip/purpose 44 and chainIndex 1 (change), 7 is the greatest unused addressIndex
90
+ * - for bip/purpose 84 and chainIndex 0 (receive), 1 is the greatest unused addressIndex
91
+ * - for bip/purpose 84 and chainIndex 1 (change), 10 is the greatest unused addressIndex
92
+ */
93
+
94
+ const chains = purposes.map((purpose) => ({
95
+ purpose,
96
+ chain: unusedAddressIndexes.find((indexes) => indexes.purpose === purpose)?.chain || [0, 0],
97
+ }))
98
+
99
+ // cloning
100
+ const newChains = chains.map(({ purpose, chain }) => ({ purpose, chain: [...chain] }))
101
+
102
+ const addrMap = {}
103
+ const purposeMap = {}
104
+
105
+ // NOTE: the hope is that this gets replaced with a fetch by xpub method and then following the chain here won't be necessary
106
+
107
+ const aggregateAddresses = async (chainObjects) => {
108
+ const promises = []
109
+ for (let chainObject of chainObjects) {
110
+ const { purpose, chainIndex, startAddressIndex, endAddressIndex } = chainObject
111
+ for (let addressIndex = startAddressIndex; addressIndex < endAddressIndex; addressIndex++) {
112
+ promises.push(
113
+ assetClientInterface
114
+ .getAddress({
115
+ assetName,
116
+ walletAccount,
117
+ purpose,
118
+ chainIndex,
119
+ addressIndex,
120
+ })
121
+ .then((address) => ({ address, purpose }))
122
+ )
123
+ }
124
+ }
125
+ const addresses = []
126
+ const result = await Promise.all(promises)
127
+ result.forEach(({ address, purpose }) => {
128
+ addrMap[String(address)] = address
129
+ purposeMap[String(address)] = purpose
130
+ addresses.push(String(address))
131
+ })
132
+ return addresses
133
+ }
134
+
135
+ const fetchAllTxs = async (addresses) => {
136
+ if (addresses.length === 0) return []
137
+
138
+ const promises = []
139
+ const fetchTimeout = 250
140
+ const fetchLimit = refresh && isIos && assetName !== 'decred' ? 50 : 10
141
+ const fetchAddressesSize = 25
142
+ for (let i = 0; i < addresses.length; i += fetchAddressesSize) {
143
+ const promise = insightClient.fetchAllTxData(
144
+ addresses.slice(i, i + fetchAddressesSize),
145
+ fetchLimit,
146
+ fetchTimeout,
147
+ shouldStop
148
+ )
149
+ promises.push(promise)
150
+ }
151
+
152
+ const txArrays = await Promise.all(promises)
153
+ return txArrays.reduce((total, some) => total.concat(some), [])
154
+ }
155
+
156
+ const receiveGapLimit = 10
157
+ const restoreGapLimit = 10
158
+ const changeGapLimit = 10
159
+ const gapLimits = [receiveGapLimit, changeGapLimit]
160
+ const gapSearchParameters = newChains.map(({ purpose, chain }) => {
161
+ return {
162
+ purpose,
163
+ chain,
164
+ startAddressIndexes: chain.map(() => 0),
165
+ endAddressIndexes: chain.map(
166
+ (addressIndex, chainIndex) =>
167
+ refresh ? restoreGapLimit : addressIndex + (gapLimits[chainIndex] || receiveGapLimit) // right of the || should never happen
168
+ ),
169
+ }
170
+ })
171
+
172
+ let allTxs = []
173
+
174
+ for (let fetchCount = 0; ; fetchCount++) {
175
+ const chainObjects = gapSearchParameters
176
+ .map(({ purpose, chain, startAddressIndexes, endAddressIndexes }) => {
177
+ return chain.map((_, chainIndex) => {
178
+ return {
179
+ purpose,
180
+ chainIndex,
181
+ startAddressIndex: startAddressIndexes[chainIndex],
182
+ endAddressIndex: endAddressIndexes[chainIndex],
183
+ }
184
+ })
185
+ })
186
+ .flat()
187
+
188
+ const addresses = await aggregateAddresses(chainObjects)
189
+
190
+ const txs = await fetchAllTxs(addresses)
191
+ if (txs.length === 0) break
192
+ allTxs = allTxs.concat(txs)
193
+
194
+ // Update chains to see if we need to fetch more
195
+ txs.forEach((tx) =>
196
+ tx.vout.forEach((vout) => {
197
+ if (!vout.scriptPubKey) return
198
+ // this is an array because legacy multisig has multiple addresses
199
+ if (!Array.isArray(vout.scriptPubKey.addresses)) return
200
+ if (vout.scriptPubKey.addresses.length === 0) return
201
+ if (!addrMap[vout.scriptPubKey.addresses[0]]) return
202
+
203
+ const address = addrMap[vout.scriptPubKey.addresses[0]]
204
+ // this is a used address, we need to update the chain
205
+ const pd = address.meta.path.split('/')
206
+ const metaChainIndex = parseInt(pd[1])
207
+ const metaAddressIndex = parseInt(pd[2])
208
+ const addressString = String(address)
209
+ const purposeToUpdate = purposeMap[addressString]
210
+ if (!purposeToUpdate) {
211
+ console.warn(`${assetName}: Cannot resolve purpose from address ${addressString}`)
212
+ return
213
+ }
214
+ const chainToUpgrade = newChains.find((newChain) => newChain.purpose === purposeToUpdate)
215
+ if (!chainToUpgrade) {
216
+ console.log(
217
+ `${assetName}: There is no chain info for purpose ${purposeToUpdate} and address ${addressString}`
218
+ )
219
+ return
220
+ }
221
+ if (chainToUpgrade.chain[metaChainIndex] === undefined) {
222
+ console.log(
223
+ `${assetName}: There is no chain info for purpose ${purposeToUpdate}, address ${addressString} and chain index ${metaChainIndex}`
224
+ )
225
+ return
226
+ }
227
+ chainToUpgrade.chain[metaChainIndex] = Math.max(
228
+ metaAddressIndex + 1,
229
+ chainToUpgrade.chain[metaChainIndex]
230
+ )
231
+ })
232
+ )
233
+
234
+ gapSearchParameters.forEach((indexData) => {
235
+ indexData.startAddressIndexes = [...indexData.endAddressIndexes]
236
+ indexData.endAddressIndexes = indexData.chain.map((addressIndex, chainIndex) => {
237
+ return (
238
+ addressIndex +
239
+ (refresh && addressIndex === indexData.endAddressIndexes[chainIndex]
240
+ ? restoreGapLimit
241
+ : gapLimits[chainIndex] || receiveGapLimit)
242
+ )
243
+ })
244
+ })
245
+
246
+ // Safety check. Slow down if after 100 iterations txs are still being fetched.
247
+ if (fetchCount > 100) await yieldToUI(1000)
248
+ }
249
+
250
+ allTxs = orderTxs(allTxs)
251
+
252
+ // post process TX data
253
+ // NOTE: this can be optimized
254
+ let vinTxids = {}
255
+ let utxos = []
256
+ const utxosToRemove = []
257
+ let newTxs = []
258
+ let existingTxs = []
259
+
260
+ allTxs.forEach((txItem) => {
261
+ let txLogItem = {
262
+ txId: txItem.txid,
263
+ coinAmount: currency.ZERO,
264
+ date: txItem.time ? new Date(txItem.time * 1000) : new Date(),
265
+ coinName: assetName,
266
+ confirmations: txItem.confirmations ? 1 : 0, // we don't care about a count - this should really be block height based if we want a count
267
+ addresses: [],
268
+ error: null,
269
+ dropped: false,
270
+ selfSend: false,
271
+ data: {
272
+ // feePerKB in satoshis, so multiply by 1e8
273
+ feePerKB: txItem.fees ? (txItem.fees / txItem.vsize) * 1000 * 1e8 : null,
274
+ rbfEnabled: txItem.rbf,
275
+ blocksSeen: 0,
276
+ },
277
+ }
278
+
279
+ let from = []
280
+
281
+ // if txItem.vin has an address that matches ours, means we've spent this tx
282
+ let isSent = false
283
+ txItem.vin.forEach((vin) => {
284
+ // It's an coinbase vin
285
+ if (Object.keys(vin).length === 0) return
286
+
287
+ vinTxids[`${vin.txid}-${vin.vout}`] = true
288
+ from.push(vin.addr)
289
+
290
+ if (!addrMap[vin.addr]) return
291
+ // it came from us...
292
+ txLogItem.coinAmount = txLogItem.coinAmount.sub(currency.defaultUnit(vin.value))
293
+ isSent = true
294
+ txLogItem.data.sent = []
295
+
296
+ // this is only used to exclude the utxos in the reducer which is why we don't care about the other fields
297
+ utxosToRemove.push({
298
+ address: addrMap[vin.addr],
299
+ txId: vin.txid,
300
+ vout: vin.vout,
301
+ value: currency.defaultUnit(vin.value || 0),
302
+ })
303
+ })
304
+
305
+ // Add the inputs for unconfirmed spends in case they drop
306
+ if (
307
+ isSent &&
308
+ txItem.confirmations < 1 &&
309
+ ['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(asset.name)
310
+ ) {
311
+ txLogItem.data.inputs = UtxoCollection.fromArray(
312
+ txItem.vin.map((vin) => ({
313
+ address: addrMap[vin.addr],
314
+ script: asset.address.toScriptPubKey(vin.addr).toString('hex'),
315
+ txId: vin.txid,
316
+ vout: vin.vout,
317
+ value: currency.defaultUnit(vin.value || 0),
318
+ }))
319
+ ).toJSON()
320
+ }
321
+
322
+ // Fix self send tx
323
+ const voutAddresses = compact(
324
+ txItem.vout.map((vout) => {
325
+ if (!vout.scriptPubKey) return
326
+ // this is an array because legacy multisig has multiple addresses
327
+ if (!Array.isArray(vout.scriptPubKey.addresses)) return
328
+ if (vout.scriptPubKey.addresses.length === 0) return
329
+ return vout.scriptPubKey.addresses[0]
330
+ })
331
+ )
332
+ const isSelfSent = isSent && voutAddresses.every((v) => addrMap[v])
333
+ if (isSelfSent) {
334
+ txLogItem.to = voutAddresses[0]
335
+ }
336
+
337
+ txItem.vout.forEach((vout) => {
338
+ if (!vout.scriptPubKey) return
339
+ // this is an array because legacy multisig has multiple addresses
340
+ if (!Array.isArray(vout.scriptPubKey.addresses)) return
341
+ if (vout.scriptPubKey.addresses.length === 0) return
342
+ if (!addrMap[vout.scriptPubKey.addresses[0]]) {
343
+ if (isSent && !txLogItem.to) {
344
+ const val = currency.defaultUnit(vout.value)
345
+ txLogItem.data.sent.push({ address: vout.scriptPubKey.addresses[0], amount: val })
346
+ }
347
+ return
348
+ }
349
+
350
+ const address = addrMap[vout.scriptPubKey.addresses[0]]
351
+ if (isReceiveAddress(address)) {
352
+ txLogItem.addresses.push(address)
353
+ }
354
+
355
+ if (isSent && isChangeAddress(address)) {
356
+ txLogItem.data.changeAddress = address
357
+ }
358
+
359
+ // it was sent to us...
360
+ let val = currency.defaultUnit(vout.value)
361
+ txLogItem.coinAmount = txLogItem.coinAmount.add(val)
362
+
363
+ const output = {
364
+ address,
365
+ txId: txItem.txid,
366
+ vout: vout.n,
367
+ confirmations: txLogItem.confirmations,
368
+ script: vout.scriptPubKey.hex,
369
+ value: val,
370
+ rbfEnabled: txItem.rbf,
371
+ }
372
+
373
+ if (assetName === 'ravencoin') {
374
+ try {
375
+ if (isRVNAssetScript(Buffer.from(output.script, 'hex'))) return
376
+ } catch (err) {
377
+ console.warn(err)
378
+ return
379
+ }
380
+ }
381
+ if (vout.spentTxId) return
382
+ utxos.push(output) // but save the unspent ones for state.utxos
383
+ })
384
+
385
+ // BCH: convert to cash format
386
+ if (assetName === 'bcash') {
387
+ if (txLogItem.to) {
388
+ txLogItem.to = asset.address.toCashAddress(txLogItem.to)
389
+ }
390
+
391
+ from = from.map(asset.address.toCashAddress)
392
+ }
393
+
394
+ if (isSent) {
395
+ txLogItem.feeAmount = currency.defaultUnit(txItem.fees || 0)
396
+ if (isSelfSent) {
397
+ txLogItem.selfSend = true
398
+ txLogItem.coinAmount = currency.ZERO
399
+ } else {
400
+ // we want coinAmount to be without the fee
401
+ // so far coinAmount = -vins + vouts (to us) = - (sent amount + fees)
402
+ // add fees to get only sent amount
403
+ txLogItem.coinAmount = txLogItem.coinAmount.add(txLogItem.feeAmount)
404
+ }
405
+ } else {
406
+ txLogItem.from = from
407
+ }
408
+
409
+ if (currentTxs.has(txLogItem.txId)) {
410
+ const existingItem = currentTxs.get(txLogItem.txId)
411
+ // we only want to detect changes when it matters - when a tx confirms
412
+ if (
413
+ existingItem.confirmations !== txLogItem.confirmations ||
414
+ existingItem.selfSend !== txLogItem.selfSend ||
415
+ existingItem.dropped
416
+ ) {
417
+ existingTxs.push(txLogItem)
418
+ }
419
+
420
+ delete unconfirmedTxsToCheck[txLogItem.txId] // we remove what we find, so we are left with what wasn't found
421
+ } else {
422
+ newTxs.push(txLogItem)
423
+ }
424
+ })
425
+
426
+ // this protects from the server returning bad spentTxId
427
+ utxos = utxos.filter((utxo) => {
428
+ if (!vinTxids[`${utxo.txId}-${utxo.vout}`]) return utxo
429
+ })
430
+
431
+ let utxoCol = UtxoCollection.fromArray(utxos, { currency })
432
+
433
+ const utxosToRemoveCol = UtxoCollection.fromArray(utxosToRemove, { currency })
434
+ // if something else can update currentUtxos in the meantime, we probably need to grab it from state again
435
+ utxoCol = utxoCol.union(currentUtxos.difference(utxosToRemoveCol))
436
+
437
+ for (let tx of Object.values(unconfirmedTxsToCheck)) {
438
+ existingTxs.push({ ...tx, dropped: true }) // TODO: this will decrease the chain index, it shouldn't be an issue considering the gap limit
439
+ utxoCol = utxoCol.difference(utxoCol.getTxIdUtxos(tx.txId))
440
+ const utxosToAdd = []
441
+ if (tx.data.inputs) {
442
+ const prevUtxos = UtxoCollection.fromJSON(tx.data.inputs, { currency })
443
+ for (let utxo of prevUtxos.toArray()) {
444
+ if (vinTxids[`${utxo.txId}-${utxo.vout}`]) {
445
+ // This utxo was already spent in another tx
446
+ continue
447
+ }
448
+ const tx = await insightClient.fetchTx(utxo.txId)
449
+ if (tx) {
450
+ // previously spent tx still exists, readd utxo
451
+ utxosToAdd.push(utxo)
452
+ }
453
+ }
454
+ }
455
+ utxoCol = utxoCol.union(UtxoCollection.fromArray(utxosToAdd, { currency }))
456
+ }
457
+
458
+ // no changes, ignore
459
+ if (utxoCol.equals(currentUtxos)) {
460
+ utxoCol = null
461
+ }
462
+
463
+ const changedUnusedAddressIndexes = newChains.filter(({ purpose, chain }, index) => {
464
+ const originalChain = chains[index]
465
+ assert(originalChain, `originalChain with purpose ${purpose} not found!`)
466
+ return !isEqual(chain, originalChain.chain)
467
+ })
468
+
469
+ return {
470
+ txsToUpdate: existingTxs,
471
+ txsToAdd: newTxs,
472
+ utxos: utxoCol,
473
+ changedUnusedAddressIndexes,
474
+ }
475
+ }