@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,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
|
+
}
|