@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.
- package/package.json +36 -0
- package/src/account-state.js +11 -0
- package/src/bitcoinjs-lib/ecc/index.js +81 -0
- package/src/bitcoinjs-lib/index.js +4 -0
- package/src/bitcoinjs-lib/script-classify/index.js +49 -0
- package/src/btc-address.js +7 -0
- package/src/btc-like-address.js +110 -0
- package/src/btc-like-keys.js +142 -0
- package/src/constants/bip44.js +27 -0
- package/src/index.js +12 -0
- package/src/insight-api-client/index.js +209 -0
- package/src/insight-api-client/util.js +110 -0
- package/src/insight-api-client/ws.js +54 -0
- package/src/key-identifier.js +19 -0
- package/src/tx-log/bitcoin-monitor-scanner.js +475 -0
- package/src/tx-log/bitcoin-monitor.js +231 -0
- package/src/tx-log/index.js +1 -0
- package/src/unconfirmed-ancestor-data.js +51 -0
- package/src/utxos-utils.js +15 -0
|
@@ -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
|
+
}
|