@exodus/ethereum-api 2.8.2 → 2.8.4

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,271 @@
1
+ /* @flow */
2
+ import { getServer } from '@exodus/ethereum-api'
3
+ import {
4
+ type PendingTransactionsDictionary,
5
+ checkPendingTransactions,
6
+ getAllLogItemsByAsset,
7
+ getDeriveDataNeededForTick,
8
+ getDeriveTransactionsToCheck,
9
+ getHistoryFromServer,
10
+ isRpcBalanceAsset,
11
+ getAssetAddresses,
12
+ } from '@exodus/ethereum-lib'
13
+
14
+ import {
15
+ enableWSUpdates,
16
+ subscribeToGasPriceNotifications,
17
+ subscribeToWSNotifications,
18
+ } from './ws-updates'
19
+
20
+ import { isEmpty } from 'lodash'
21
+
22
+ import { type Tx } from '@exodus/models'
23
+
24
+ import { BaseMonitor } from '@exodus/asset-lib'
25
+
26
+ export type { AssetSource } from '@exodus/models/lib/types'
27
+
28
+ type DerivedData = {
29
+ ourWalletAddress: string,
30
+ currentAccountState: Object,
31
+ minimumConfirmations: number,
32
+ unconfirmedTransactions: PendingTransactionsDictionary,
33
+ pendingTransactionsGroupedByAddressAndNonce: PendingTransactionsDictionary,
34
+ simulatedTransactions: any,
35
+ }
36
+ // The base ethereum monitor class handles listening for, fetching,
37
+ // formatting, and populating-to-state all ETH/ETC/ERC20 transactions.
38
+
39
+ export class EthereumMonitor extends BaseMonitor {
40
+ constructor({ server, config, ...args }) {
41
+ super(args)
42
+ this.server = server || getServer(this.asset)
43
+ this.config = { GAS_PRICE_FROM_WEBSOCKET: true, ...config }
44
+ this.deriveDataNeededForTick = getDeriveDataNeededForTick(this.aci)
45
+ this.deriveTransactionsToCheck = getDeriveTransactionsToCheck({
46
+ getTxLog: (...args) => this.aci.getTxLog(...args),
47
+ })
48
+ this.subscribedToWalletAccountNotificationsMap = new Map()
49
+ this.addHook('before-start', (...args) => this.beforeStart(...args))
50
+ this.addHook('after-stop', (...args) => this.afterStop(...args))
51
+ this.subscribedToGasPriceMap = new Map()
52
+ }
53
+
54
+ setServer(config = {}) {
55
+ if (config.serverv1 && this.server.getURL() !== config.serverv1) {
56
+ this.server.setURL(config.serverv1)
57
+ }
58
+ }
59
+
60
+ async deriveData({ assetSource, tokens }: Object): DerivedData {
61
+ const { asset: assetName, walletAccount } = assetSource
62
+
63
+ const {
64
+ ourWalletAddress,
65
+ currentAccountState,
66
+ minimumConfirmations,
67
+ } = await this.deriveDataNeededForTick({ assetName, walletAccount })
68
+ const transactionsToCheck = await this.deriveTransactionsToCheck({
69
+ assetName,
70
+ walletAccount,
71
+ tokens,
72
+ ourWalletAddress,
73
+ })
74
+
75
+ return {
76
+ ourWalletAddress,
77
+ currentAccountState,
78
+ minimumConfirmations,
79
+ ...transactionsToCheck,
80
+ }
81
+ }
82
+
83
+ // eslint-disable-next-line no-undef
84
+ async checkPendingTransactions(params): { txsToRemove: { tx: Tx, assetSource: AssetSource }[] } {
85
+ const {
86
+ pendingTransactionsToCheck,
87
+ pendingTransactionsGroupedByAddressAndNonce,
88
+ } = checkPendingTransactions(params)
89
+ const txsToRemove = []
90
+ const {
91
+ assetSource: { walletAccount },
92
+ } = params
93
+
94
+ const updateTx = (tx, asset, { error, remove }) => {
95
+ if (remove) {
96
+ txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
97
+ } else {
98
+ params.logItemsByAsset[asset].push({
99
+ ...tx,
100
+ dropped: true,
101
+ error,
102
+ })
103
+ }
104
+
105
+ // in case this is an ETH fee tx that has associated ERC20 send txs
106
+ const promises = tx.tokens.map(async (assetName) => {
107
+ const tokenTxSet = await this.aci.getTxLog({ assetName, walletAccount })
108
+ if (remove) {
109
+ txsToRemove.push({
110
+ tx: tokenTxSet.get(tx.txId),
111
+ assetSource: { asset: assetName, walletAccount },
112
+ })
113
+ } else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
114
+ params.logItemsByAsset[assetName].push({
115
+ ...tokenTxSet.get(tx.txId),
116
+ error,
117
+ dropped: true,
118
+ })
119
+ }
120
+ })
121
+ return Promise.all(promises)
122
+ }
123
+
124
+ for (const { tx, assetName, replaced = false } of Object.values(
125
+ pendingTransactionsGroupedByAddressAndNonce
126
+ )) {
127
+ if (replaced) {
128
+ await updateTx(tx, assetName, { remove: true })
129
+ delete pendingTransactionsToCheck[tx.txId]
130
+ }
131
+ }
132
+
133
+ for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
134
+ if (params.refresh) await updateTx(tx, assetName, { remove: true })
135
+ else await updateTx(tx, assetName, { error: 'Dropped' })
136
+ }
137
+
138
+ return {
139
+ txsToRemove,
140
+ }
141
+ }
142
+
143
+ async tick({ refresh, walletAccount }) {
144
+ await this._subscribeToWSNotifications() // checks if it's a new walletAccount
145
+
146
+ const assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
147
+ const tokens = Object.values(assets).filter((asset) => asset.baseAsset.name !== asset.name)
148
+ const tokensByAddress = tokens.reduce((map, token) => {
149
+ const addresses = getAssetAddresses(token)
150
+ for (const address of addresses) map.set(address.toLowerCase(), token)
151
+ return map
152
+ }, new Map())
153
+ const assetSource = { asset: this.asset.name, walletAccount }
154
+
155
+ const derivedData = await this.deriveData({ assetSource, tokens })
156
+
157
+ const { allTransactionsFromServer, index } = await getHistoryFromServer({
158
+ server: this.server,
159
+ ourWalletAddress: derivedData.ourWalletAddress,
160
+ minimumConfirmations: derivedData.minimumConfirmations,
161
+ index: refresh ? 0 : derivedData.currentAccountState?.index ?? 0,
162
+ })
163
+
164
+ const logItemsByAsset = getAllLogItemsByAsset({
165
+ ourWalletAddress: derivedData.ourWalletAddress,
166
+ allTransactionsFromServer,
167
+ asset: this.asset,
168
+ tokensByAddress,
169
+ assets,
170
+ })
171
+
172
+ const { txsToRemove } = await this.checkPendingTransactions({
173
+ txlist: allTransactionsFromServer,
174
+ assetSource,
175
+ refresh,
176
+ logItemsByAsset,
177
+ asset: this.asset,
178
+ ...derivedData,
179
+ })
180
+
181
+ const accountState = await this.getNewAccountState({
182
+ tokens,
183
+ ourWalletAddress: derivedData.ourWalletAddress,
184
+ })
185
+ await this.updateAccountState({ newData: { index, ...accountState }, walletAccount })
186
+
187
+ await this.removeFromTxLog(txsToRemove)
188
+ await this.updateTxLogByAsset({ logItemsByAsset, walletAccount, refresh })
189
+ }
190
+
191
+ async updateGasPrice(newGasPrice) {
192
+ try {
193
+ const feeConfig = { gasPrice: `${parseInt(newGasPrice, 16)} wei` }
194
+ this.logger.debug(`Update ${this.asset.name} gas price: ${feeConfig.gasPrice}`)
195
+ await this.aci.updateFeeConfig({ assetName: this.asset.name, feeConfig })
196
+ } catch (e) {
197
+ this.logger.warn('error updating gasPrice', e)
198
+ }
199
+ }
200
+
201
+ async getNewAccountState({ tokens, ourWalletAddress }) {
202
+ const asset = this.asset
203
+ const newAccountState = {}
204
+ const server = this.server
205
+ if (isRpcBalanceAsset(asset)) {
206
+ const { confirmed } = await server.getBalance(ourWalletAddress)
207
+ newAccountState.balance = asset.currency.baseUnit(confirmed.value)
208
+ }
209
+ const tokenBalances = Object.assign(
210
+ {},
211
+ ...(await Promise.all(
212
+ tokens
213
+ .filter((token) => isRpcBalanceAsset(token) && token.contract.address)
214
+ .map(async (token) => {
215
+ const { confirmed } = await server.balanceOf(ourWalletAddress, token.contract.address)
216
+ const value = confirmed[token.contract.address]
217
+ const balance = token.currency.baseUnit(value || 0)
218
+ return { [token.name]: balance }
219
+ })
220
+ ))
221
+ )
222
+ if (!isEmpty(tokenBalances)) newAccountState.tokenBalances = tokenBalances
223
+ return newAccountState
224
+ }
225
+
226
+ async getReceiveAddressesByWalletAccount() {
227
+ const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
228
+ const addressesByAccount = {}
229
+ for (const walletAccount of walletAccounts) {
230
+ addressesByAccount[walletAccount] = await this.aci.getReceiveAddresses({
231
+ assetName: this.asset.name,
232
+ walletAccount,
233
+ })
234
+ }
235
+ return addressesByAccount
236
+ }
237
+
238
+ async _subscribeToWSNotifications() {
239
+ const addressesByWalletAccount = await this.getReceiveAddressesByWalletAccount()
240
+ subscribeToWSNotifications({
241
+ addressesByWalletAccount,
242
+ tick: (...args) => this.tick(...args),
243
+ server: this.server,
244
+ })
245
+ }
246
+
247
+ async beforeStart() {
248
+ const addressesByWalletAccount = await this.getReceiveAddressesByWalletAccount()
249
+ enableWSUpdates({
250
+ interval: this.interval,
251
+ server: this.server,
252
+ timer: this.timer,
253
+ tick: (...args) => this.tick(...args),
254
+ tickAllWalletAccounts: () => this.tickAllWalletAccounts(),
255
+ addressesByWalletAccount,
256
+ beforeStart: true,
257
+ })
258
+
259
+ if (this.config.GAS_PRICE_FROM_WEBSOCKET) {
260
+ subscribeToGasPriceNotifications({
261
+ server: this.server,
262
+ updateGasPrice: (newGasPrice) => this.updateGasPrice(newGasPrice),
263
+ subscribedToGasPriceMap: this.subscribedToGasPriceMap,
264
+ })
265
+ }
266
+ }
267
+
268
+ async afterStop() {
269
+ await this.server.stop()
270
+ }
271
+ }
@@ -0,0 +1 @@
1
+ export * from './ethereum-monitor'
@@ -0,0 +1,75 @@
1
+ import ms from 'ms'
2
+
3
+ const subscribedToAddressNotificationsMap = new Map()
4
+
5
+ const UPDATE_LOOP_INTERVAL = ms('5m')
6
+
7
+ export function subscribeToWSNotifications({
8
+ addressesByWalletAccount,
9
+ tick,
10
+ getState,
11
+ server,
12
+ beforeStart = false,
13
+ }) {
14
+ Object.entries(addressesByWalletAccount).forEach(([walletAccount, addresses]) => {
15
+ const address = String(Array.from(addresses)[0]).toLowerCase() // Only check m/0/0
16
+ const mapKey = `${server.getURL()}:${address}` // different chains might have the same addresses
17
+ if (subscribedToAddressNotificationsMap.has(mapKey)) return
18
+
19
+ server.ws.watch(address)
20
+ server.ws.events.on(`address-${address}`, () => {
21
+ tick({ refresh: false, getState, walletAccount })
22
+ })
23
+ subscribedToAddressNotificationsMap.set(mapKey, true)
24
+ if (!beforeStart) {
25
+ // tick for a new wallet account
26
+ tick({ refresh: false, getState, walletAccount })
27
+ }
28
+ })
29
+ }
30
+
31
+ export function subscribeToGasPriceNotifications({
32
+ server,
33
+ updateGasPrice,
34
+ subscribedToGasPriceMap,
35
+ }) {
36
+ const mapKey = server.getURL()
37
+ if (subscribedToGasPriceMap.has(mapKey)) return
38
+ server.ws.events.on('gasprice', updateGasPrice)
39
+ subscribedToGasPriceMap.set(mapKey, true)
40
+ }
41
+
42
+ export function enableWSUpdates({
43
+ interval,
44
+ server,
45
+ timer,
46
+ dispatch,
47
+ getState,
48
+ tickAllWalletAccounts,
49
+ tick,
50
+ addressesByWalletAccount,
51
+ beforeStart = false,
52
+ }) {
53
+ const defaultInterval = interval
54
+
55
+ subscribeToWSNotifications({
56
+ server,
57
+ getState,
58
+ beforeStart,
59
+ tick,
60
+ addressesByWalletAccount,
61
+ })
62
+
63
+ server.ws.events.on('open', () => {
64
+ if (timer.isRunning) {
65
+ timer.setNewInterval(UPDATE_LOOP_INTERVAL)
66
+ }
67
+ })
68
+ server.ws.events.on('close', async () => {
69
+ if (timer.isRunning) {
70
+ await timer.setNewInterval(defaultInterval)
71
+ return tickAllWalletAccounts({ dispatch, getState })
72
+ }
73
+ })
74
+ server.ws.open()
75
+ }
@@ -1,3 +1,23 @@
1
- import WebSocket from 'ws'
2
-
3
- export default WebSocket
1
+ /**
2
+ * This is a "light" version of @exodus/core resolution to select the native WebSocket on BE
3
+ * It's a workaround until https://github.com/ExodusMovement/assets/pull/72 is merged.
4
+ *
5
+ * Based on https://github.com/ExodusMovement/fetch/blob/master/core.js , note that chooses native on Desktop
6
+ */
7
+ if (
8
+ typeof process !== 'undefined' &&
9
+ process &&
10
+ (process.type === 'renderer' || process.type === 'worker')
11
+ ) {
12
+ // THIS IS FOR DESKTOP
13
+ module.exports = require('ws')
14
+ } else {
15
+ // eslint-disable-next-line no-undef
16
+ if (global.window?.WebSocket) {
17
+ // THIS IS FOR BE
18
+ module.exports = global.window?.WebSocket
19
+ } else {
20
+ // THIS IS FOR UNIT TESTING.
21
+ module.exports = require('ws')
22
+ }
23
+ }