@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,231 @@
1
+ import assert from 'minimalistic-assert'
2
+ import { isEmpty, isEqual } from 'lodash'
3
+
4
+ import { BaseMonitor } from '@exodus/asset-lib'
5
+ import InsightWSClient from '../insight-api-client/ws'
6
+ import { updateUnconfirmedAncestorData } from '../unconfirmed-ancestor-data'
7
+ import { rescanBlockchainInsight } from './bitcoin-monitor-scanner'
8
+ import { normalizeInsightConfig, toWSUrl } from '../insight-api-client/util'
9
+ import ms from 'ms'
10
+
11
+ // NOTE: this is a frankenstein mashup of Exodus desktop
12
+ // assets-refresh/insight action + Neo monitor
13
+
14
+ export class Monitor extends BaseMonitor {
15
+ #addressesByWalletAccount
16
+ #runningByWalletAccount
17
+ #wsInterval
18
+ #ws
19
+ #apiUrl
20
+ #wsUrl
21
+ #insightClient
22
+ #yieldToUI
23
+ #isIos
24
+
25
+ constructor({
26
+ asset,
27
+ interval = ms('45s'),
28
+ wsInterval = ms('5m'),
29
+ assetClientInterface,
30
+ runner,
31
+ yieldToUI,
32
+ logger,
33
+ flux,
34
+ insightClient,
35
+ apiUrl,
36
+ isIos,
37
+ }) {
38
+ super({ asset, interval, assetClientInterface, logger, runner })
39
+ assert(insightClient, 'insightClient is required!')
40
+ assert(apiUrl, 'apiUrl is required')
41
+ assert(yieldToUI, 'yieldToUI is required!')
42
+ this.#wsInterval = wsInterval
43
+ this.#ws = null
44
+ this.#apiUrl = apiUrl
45
+ this.#yieldToUI = yieldToUI
46
+ this.#wsUrl = null
47
+ this.#runningByWalletAccount = {}
48
+ this.#addressesByWalletAccount = {}
49
+ this.#insightClient = insightClient
50
+ this.#isIos = isIos
51
+ this.addHook('after-tick-multiple-wallet-accounts', () => this.#subscribeToNewAddresses())
52
+ }
53
+
54
+ setServer(assetConfig = {}) {
55
+ const { apiUrl, wsUrl } = normalizeInsightConfig(assetConfig)
56
+ if (apiUrl) {
57
+ this.#insightClient.setBaseUrl(apiUrl)
58
+ }
59
+ if (!this.#wsUrl || this.#wsUrl !== wsUrl) {
60
+ this.#connectWS(wsUrl || this.#wsUrl || toWSUrl(this.#apiUrl))
61
+ }
62
+ }
63
+
64
+ #getReceiveAddressesByWalletAccount = async () => {
65
+ const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
66
+ return Object.fromEntries(
67
+ await Promise.all(
68
+ walletAccounts.map(async (walletAccount) => [
69
+ walletAccount,
70
+ await this.aci.getReceiveAddresses({
71
+ multiAddressMode: true,
72
+ assetName: this.asset.name,
73
+ walletAccount,
74
+ }),
75
+ ])
76
+ )
77
+ )
78
+ }
79
+
80
+ #connectWS = async (wsUrl) => {
81
+ const aci = this.aci
82
+ if (this.#ws) {
83
+ this.#ws.close()
84
+ this.#ws = null
85
+ }
86
+ this.#wsUrl = wsUrl
87
+ if (!wsUrl) {
88
+ return
89
+ }
90
+
91
+ this.#addressesByWalletAccount = await this.#getReceiveAddressesByWalletAccount()
92
+
93
+ const addressesArr = Object.values(this.#addressesByWalletAccount).flat()
94
+
95
+ if (!addressesArr.length) {
96
+ this.#logWsStatus('no addressesArr to subscribe')
97
+ return
98
+ }
99
+
100
+ this.#ws = new InsightWSClient(wsUrl, this.asset.name)
101
+ this.#ws.connect(addressesArr)
102
+ this.#ws.on('connect', () => {
103
+ this.#logWsStatus('connect to addresses', addressesArr)
104
+ this.timer.setNewInterval(this.#wsInterval)
105
+ })
106
+ this.#ws.on('message', ({ address }) => {
107
+ const walletAccount = Object.keys(this.#addressesByWalletAccount).find((walletAccount) =>
108
+ this.#addressesByWalletAccount[walletAccount].includes(address)
109
+ )
110
+ this.tickWalletAccounts({ walletAccount })
111
+ })
112
+ this.#ws.on('block', async () => {
113
+ await this.#yieldToUI(100)
114
+ const assetName = this.asset.name
115
+
116
+ const walletAccounts = await aci.getWalletAccounts({ assetName })
117
+ for (const walletAccount of walletAccounts) {
118
+ const currentTxs = Array.from(await aci.getTxLog({ assetName, walletAccount }))
119
+ const statuses = await Promise.all(
120
+ currentTxs.map(async (tx) => {
121
+ if (tx.dropped || (tx.confirmations && tx.confirmations > 0)) return null
122
+ const txStatus = await this.#insightClient.fetchTx(tx.txId)
123
+ if (!txStatus) return null
124
+ if (txStatus.confirmations <= 0) return null
125
+ return { txId: tx.txId, confirmations: txStatus.confirmations }
126
+ })
127
+ ).then((res) => res.filter((status) => !!status))
128
+ await aci.updateTxLog({ assetName, walletAccount, txs: statuses })
129
+
130
+ const accountState = await aci.getAccountState({ assetName, walletAccount })
131
+
132
+ const utxos = accountState.utxos.updateConfirmations(statuses)
133
+ await aci.updateAccountState({ accountState, assetName, walletAccount, newData: { utxos } })
134
+
135
+ if (['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) {
136
+ updateUnconfirmedAncestorData({
137
+ asset: this.asset,
138
+ walletAccount,
139
+ accountState,
140
+ insightClient: this.#insightClient,
141
+ })
142
+ }
143
+ }
144
+ })
145
+ this.#ws.on('disconnect', () => {
146
+ this.#logWsStatus('disconnect')
147
+ this.timer.setNewInterval(this.interval)
148
+ })
149
+ this.#ws.on('reconnect', () => {
150
+ this.#logWsStatus('reconnect')
151
+ this.tickWalletAccounts()
152
+ })
153
+ }
154
+
155
+ #subscribeToNewAddresses = async () => {
156
+ const newAddressesByWalletAccount = await this.#getReceiveAddressesByWalletAccount()
157
+ if (!isEqual(newAddressesByWalletAccount, this.#addressesByWalletAccount)) {
158
+ await this.#connectWS(this.#wsUrl)
159
+ }
160
+ }
161
+
162
+ tick = async ({ walletAccount, refresh }) => {
163
+ assert(walletAccount, 'walletAccount is expected')
164
+ if (this.#runningByWalletAccount[walletAccount]) return
165
+ this.#runningByWalletAccount[walletAccount] = true
166
+ try {
167
+ await this.#syncWalletAccount({ walletAccount, refresh })
168
+ } finally {
169
+ delete this.#runningByWalletAccount[walletAccount]
170
+ }
171
+ }
172
+
173
+ #syncWalletAccount = async ({ walletAccount, refresh }) => {
174
+ const aci = this.aci
175
+ const assetName = this.asset.name
176
+ // wait for all wallet accounts to load
177
+ await aci.getWalletAccounts({ assetName })
178
+
179
+ const {
180
+ txsToAdd,
181
+ txsToUpdate,
182
+ utxos,
183
+ changedUnusedAddressIndexes,
184
+ } = await rescanBlockchainInsight({
185
+ asset: this.asset,
186
+ walletAccount,
187
+ refresh,
188
+ assetClientInterface: aci,
189
+ insightClient: this.#insightClient,
190
+ yieldToUI: this.#yieldToUI,
191
+ isIos: this.#isIos,
192
+ })
193
+ const accountState = await aci.getAccountState({ assetName, walletAccount })
194
+
195
+ if (utxos) await aci.updateAccountState({ assetName, walletAccount, newData: { utxos } })
196
+
197
+ if (!isEmpty(changedUnusedAddressIndexes)) {
198
+ // Only for mobile atm, browser and hydra calculates from the latest txLogs
199
+ await aci.saveUnusedAddressIndexes({
200
+ assetName,
201
+ walletAccount,
202
+ changedUnusedAddressIndexes,
203
+ })
204
+ }
205
+
206
+ const txs = [...txsToUpdate, ...txsToAdd]
207
+
208
+ if (txs.length) {
209
+ await this.updateTxLog({
210
+ assetName,
211
+ logItems: txs,
212
+ walletAccount,
213
+ refresh,
214
+ })
215
+ }
216
+
217
+ // Move to after tick
218
+ if (['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) {
219
+ updateUnconfirmedAncestorData({
220
+ asset: this.asset,
221
+ walletAccount,
222
+ accountState,
223
+ insightClient: this.#insightClient,
224
+ })
225
+ }
226
+ }
227
+
228
+ #logWsStatus = (message, ...args) => {
229
+ // console.debug('btc-like monitor', this.asset.name, message, ...args)
230
+ }
231
+ }
@@ -0,0 +1 @@
1
+ export * from './bitcoin-monitor'
@@ -0,0 +1,51 @@
1
+ import { getUtxos } from './utxos-utils'
2
+
3
+ const dataMap = new Map()
4
+
5
+ export async function updateUnconfirmedAncestorData({
6
+ asset,
7
+ walletAccount,
8
+ accountState,
9
+ insightClient,
10
+ }) {
11
+ for (const [txId, stored] of dataMap) {
12
+ if (stored.walletAccount === walletAccount) dataMap.delete(txId)
13
+ }
14
+
15
+ const utxos = getUtxos({ accountState, asset })
16
+ .toArray()
17
+ .filter((data) => {
18
+ return !data.confirmations || data.confirmations <= 0
19
+ })
20
+ const txIds = new Set(utxos.map(({ txId }) => txId))
21
+ for (const txId of txIds) {
22
+ try {
23
+ const { size, fees } = await insightClient.fetchUnconfirmedAncestorData(txId)
24
+ if (size !== 0) {
25
+ dataMap.set(txId, { assetName: asset.name, walletAccount, size, fees })
26
+ }
27
+ } catch (e) {
28
+ console.warn(e)
29
+ }
30
+ }
31
+ }
32
+
33
+ function getUnconfirmedAncestorData({ txId, assetName }) {
34
+ const stored = dataMap.get(txId)
35
+ if (stored?.assetName === assetName) {
36
+ return { size: stored.size, fees: stored.fees }
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ export function resolveExtraFeeOfTx({ assetName, feeRate, txId }) {
42
+ const data = getUnconfirmedAncestorData({ assetName, txId })
43
+ if (!data) return 0
44
+ const { fees, size } = data
45
+ // Get the difference in fee rate between ancestors and current estimate
46
+ const feeRateDiff = feeRate - fees / size
47
+ // If the diff is negative, ancestors already pay more than current estimate
48
+ if (feeRateDiff <= 0) return 0
49
+ // Calculate enough fee to bring the fee rate of ancestors to current estimate
50
+ return feeRateDiff * size
51
+ }
@@ -0,0 +1,15 @@
1
+ import { UtxoCollection } from '@exodus/models'
2
+
3
+ export function getUtxos({ accountState, asset }) {
4
+ return (
5
+ accountState?.utxos ||
6
+ UtxoCollection.createEmpty({
7
+ currency: asset.currency,
8
+ })
9
+ )
10
+ }
11
+
12
+ export function getBalances({ asset, accountState }) {
13
+ const balance = getUtxos({ asset, accountState }).value
14
+ return balance.isZero ? null : { balance }
15
+ }