@exodus/ethereum-api 3.3.1 → 3.3.4-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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "3.3.1",
3
+ "version": "3.3.4-alpha.0",
4
4
  "description": "Ethereum Api",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -17,7 +17,7 @@
17
17
  "@exodus/asset-lib": "^3.7.1",
18
18
  "@exodus/bip32": "^1.0.0",
19
19
  "@exodus/crypto": "^1.0.0-rc.0",
20
- "@exodus/ethereum-lib": "^2.24.1",
20
+ "@exodus/ethereum-lib": "^2.24.3-alpha.0",
21
21
  "@exodus/ethereumjs-util": "^7.1.0-exodus.6",
22
22
  "@exodus/fetch": "^1.2.1",
23
23
  "@exodus/key-utils": "^1.0.0",
@@ -30,6 +30,7 @@
30
30
  "make-concurrent": "4.0.0",
31
31
  "minimalistic-assert": "^1.0.1",
32
32
  "ms": "^2.1.1",
33
+ "socket.io-client": "^4.5.4",
33
34
  "url": "0.10.3",
34
35
  "url-join": "4.0.0",
35
36
  "ws": "^6.1.0"
@@ -37,5 +38,5 @@
37
38
  "devDependencies": {
38
39
  "@exodus/models": "^8.10.4"
39
40
  },
40
- "gitHead": "d4953e5b81e4638d0d8b715bbb81f41c7dc9e970"
41
+ "gitHead": "1309eea2af6fa13a3e9e36e6aa615f863ab73012"
41
42
  }
@@ -0,0 +1,302 @@
1
+ import { bufferToHex } from '@exodus/ethereumjs-util'
2
+ import SolidityContract from '@exodus/solidity-contract'
3
+ import EventEmitter from 'events'
4
+ import { io } from 'socket.io-client'
5
+ import { ASSET_NAMESPACES } from '@exodus/ethereum-lib'
6
+
7
+ export default class ClarityServer extends EventEmitter {
8
+ constructor({ baseAssetName, uri }) {
9
+ super()
10
+ this.baseAssetName = baseAssetName
11
+ this.uri = uri
12
+ this.baseNamespace = `/v1/${ASSET_NAMESPACES[this.baseAssetName]}`
13
+ this.sockets = {}
14
+ this.id = 0
15
+ }
16
+
17
+ setURI(uri) {
18
+ this.dispose()
19
+ this.uri = uri
20
+ }
21
+
22
+ connectTransactions({ walletAccount, address }) {
23
+ const namespace = this.formatTransactionsNamespace(address)
24
+ if (!this.sockets[namespace]) {
25
+ this.sockets[namespace] = this.createSocket(namespace)
26
+ this.sockets[namespace].on('transaction', (isPending, transaction) => {
27
+ this.emit('transaction', { walletAccount, isPending, transaction })
28
+ })
29
+ }
30
+ return this.sockets[namespace]
31
+ }
32
+
33
+ connectRpc() {
34
+ const namespace = this.formatRpcNamespace()
35
+ if (!this.sockets[namespace]) {
36
+ this.sockets[namespace] = this.createSocket(namespace)
37
+ }
38
+ return this.sockets[namespace]
39
+ }
40
+
41
+ connectFee() {
42
+ const namespace = this.formatFeeNamespace()
43
+ if (!this.sockets[namespace]) {
44
+ this.sockets[namespace] = this.createSocket(namespace)
45
+ this.sockets[namespace].on('feeUpdated', (fee) => this.emit('feeUpdated', fee))
46
+ }
47
+ return this.sockets[namespace]
48
+ }
49
+
50
+ createSocket(namespace) {
51
+ return io(`${this.uri}${namespace}`)
52
+ }
53
+
54
+ disconnectTransactions(address) {
55
+ const namespace = this.formatTransactionsNamespace(address)
56
+ this.disconnectSocket(namespace)
57
+ }
58
+
59
+ disconnectRpc() {
60
+ this.disconnectSocket(this.rpcNamespace)
61
+ }
62
+
63
+ disconnectFee() {
64
+ this.disconnectSocket(this.feeNamespace)
65
+ }
66
+
67
+ disconnectSocket(namespace) {
68
+ if (this.sockets[namespace]) {
69
+ this.sockets[namespace].disconnect()
70
+ delete this.sockets[namespace]
71
+ }
72
+ }
73
+
74
+ dispose() {
75
+ const namespaces = Object.keys(this.sockets)
76
+ for (const namespace of namespaces) {
77
+ this.disconnectSocket(namespace)
78
+ }
79
+ }
80
+
81
+ async getAllTransactions(params) {
82
+ const transactions = { pending: [], confirmed: [] }
83
+ const cursor = await this.getTransactions({
84
+ ...params,
85
+ onChunk: (isPending, chunk) => {
86
+ if (isPending) {
87
+ transactions.pending.push(...chunk)
88
+ } else {
89
+ transactions.confirmed.push(...chunk)
90
+ }
91
+ },
92
+ })
93
+ return { cursor, transactions }
94
+ }
95
+
96
+ async getTransactions({ walletAccount, address, cursor, onChunk }) {
97
+ const socket = this.connectTransactions({ walletAccount, address })
98
+ const listener = (isPending, chunk, callback) => {
99
+ onChunk(isPending, chunk)
100
+ callback()
101
+ }
102
+ socket.on('transactionsChunk', listener)
103
+ return new Promise((resolve) => {
104
+ socket.emit('getTransactions', cursor, (nextCursor) => {
105
+ resolve(nextCursor)
106
+ })
107
+ })
108
+ .catch((error) => console.error(error))
109
+ .finally(() => socket.off('transactionsChunk', listener))
110
+ }
111
+
112
+ async getFee() {
113
+ const socket = this.connectFee()
114
+ return new Promise((resolve, reject) => {
115
+ socket.timeout(30000).emit('getFee', (fee) => {
116
+ if (!fee) {
117
+ const error = new Error('Unable to get fee')
118
+ return reject(error)
119
+ }
120
+ resolve(fee)
121
+ })
122
+ })
123
+ }
124
+
125
+ async sendRpcRequest(rpcRequest) {
126
+ const rpcSocket = this.connectRpc()
127
+ return new Promise((resolve, reject) => {
128
+ rpcSocket.timeout(30000).emit('request', rpcRequest, (error, response) => {
129
+ if (error) {
130
+ return reject(error)
131
+ }
132
+ resolve(response)
133
+ })
134
+ })
135
+ }
136
+
137
+ async sendBatchRequest(batch) {
138
+ const responses = await this.sendRpcRequest(batch)
139
+ const isValid = responses.every((response) => {
140
+ return !isNaN(response?.id) && response?.result
141
+ })
142
+ if (responses.length !== batch.length || !isValid) {
143
+ throw new Error('Bad Response')
144
+ }
145
+ const keyed = responses.reduce((acc, response) => {
146
+ return { ...acc, [`${response.id}`]: response.result }
147
+ }, {})
148
+ return batch.map((request) => keyed[`${request.id}`])
149
+ }
150
+
151
+ async sendRequest(request) {
152
+ const response = await this.sendRpcRequest(request)
153
+ if (!response?.result) {
154
+ throw new Error('Bad Response')
155
+ }
156
+ return response.result
157
+ }
158
+
159
+ buildRequest({ method, params = [] }) {
160
+ return { jsonrpc: '2.0', id: this.id++, method, params }
161
+ }
162
+
163
+ balanceOfRequest(address, tokenAddress, tag = 'latest') {
164
+ const contract = SolidityContract.simpleErc20(tokenAddress)
165
+ const callData = contract.balanceOf.build(address)
166
+ const data = {
167
+ data: bufferToHex(callData),
168
+ to: tokenAddress,
169
+ }
170
+ return this.ethCallRequest(data, tag)
171
+ }
172
+
173
+ getBalanceRequest(address, tag = 'latest') {
174
+ return this.buildRequest({ method: 'eth_getBalance', params: [address, tag] })
175
+ }
176
+
177
+ gasPriceRequest() {
178
+ return this.buildRequest({ method: 'eth_gasPrice' })
179
+ }
180
+
181
+ estimateGasRequest(data, tag = 'latest') {
182
+ return this.buildRequest({ method: 'eth_estimateGas', params: [data, tag] })
183
+ }
184
+
185
+ sendRawTransactionRequest(data) {
186
+ const hex = data.startsWith('0x') ? data : '0x' + data
187
+ return this.buildRequest({ method: 'eth_sendRawTransaction', params: [hex] })
188
+ }
189
+
190
+ getCodeRequest(address, tag = 'latest') {
191
+ return this.buildRequest({ method: 'eth_getCode', params: [address, tag] })
192
+ }
193
+
194
+ getTransactionCountRequest(address, tag = 'latest') {
195
+ return this.buildRequest({ method: 'eth_getTransactionCount', params: [address, tag] })
196
+ }
197
+
198
+ getTransactionByHashRequest(hash) {
199
+ return this.buildRequest({ method: 'eth_getTransactionByHash', params: [hash] })
200
+ }
201
+
202
+ getTransactionReceiptRequest(txhash) {
203
+ return this.buildRequest({ method: 'eth_getTransactionReceipt', params: [txhash] })
204
+ }
205
+
206
+ ethCallRequest(data, tag) {
207
+ return this.buildRequest({ method: 'eth_call', params: [data, tag] })
208
+ }
209
+
210
+ blockNumberRequest() {
211
+ return this.buildRequest({ method: 'eth_blockNumber' })
212
+ }
213
+
214
+ getBlockByNumberRequest(numberHex, isFullTxs = false) {
215
+ return this.buildRequest({ method: 'eth_getBlockByNumber', params: [numberHex, isFullTxs] })
216
+ }
217
+
218
+ simulateRawTransactionRequest(rawTx, applyPending = true) {
219
+ const replaced = rawTx.replace('0x', '')
220
+ return this.buildRequest({
221
+ method: 'debug_simulateRawTransaction',
222
+ params: [replaced, applyPending],
223
+ })
224
+ }
225
+
226
+ async balanceOf(...params) {
227
+ const request = this.balanceOfRequest(...params)
228
+ return this.sendRequest(request)
229
+ }
230
+
231
+ async getBalance(...params) {
232
+ const request = this.getBalanceRequest(...params)
233
+ return this.sendRequest(request)
234
+ }
235
+
236
+ async gasPrice(...params) {
237
+ const request = this.gasPriceRequest(...params)
238
+ return this.sendRequest(request)
239
+ }
240
+
241
+ async estimateGas(...params) {
242
+ const request = this.estimateGasRequest(...params)
243
+ return this.sendRequest(request)
244
+ }
245
+
246
+ async sendRawTransaction(...params) {
247
+ const request = this.sendRawTransactionRequest(...params)
248
+ return this.sendRequest(request)
249
+ }
250
+
251
+ async getCode(...params) {
252
+ const request = this.getCodeRequest(...params)
253
+ return this.sendRequest(request)
254
+ }
255
+
256
+ async getTransactionCount(...params) {
257
+ const request = this.getTransactionCountRequest(...params)
258
+ return this.sendRequest(request)
259
+ }
260
+
261
+ async getTransactionByHash(...params) {
262
+ const request = this.getTransactionByHashRequest(...params)
263
+ return this.sendRequest(request)
264
+ }
265
+
266
+ async getTransactionReceipt(...params) {
267
+ const request = this.getTransactionReceiptRequest(...params)
268
+ return this.sendRequest(request)
269
+ }
270
+
271
+ async ethCall(...params) {
272
+ const request = this.ethCallRequest(...params)
273
+ return this.sendRequest(request)
274
+ }
275
+
276
+ async blockNumber(...params) {
277
+ const request = this.blockNumberRequest(...params)
278
+ return this.sendRequest(request)
279
+ }
280
+
281
+ async getBlockByNumber(...params) {
282
+ const request = this.getBlockByNumberRequest(...params)
283
+ return this.sendRequest(request)
284
+ }
285
+
286
+ async simulateRawTransaction(...params) {
287
+ const request = this.simulateRawTransactionRequest(...params)
288
+ return this.sendRequest(request)
289
+ }
290
+
291
+ formatTransactionsNamespace(address) {
292
+ return `${this.baseNamespace}/addresses/${address}/transactions`
293
+ }
294
+
295
+ formatRpcNamespace() {
296
+ return `${this.baseNamespace}/rpc`
297
+ }
298
+
299
+ formatFeeNamespace() {
300
+ return `${this.baseNamespace}/fee`
301
+ }
302
+ }
@@ -1,11 +1,14 @@
1
1
  import { DEFAULT_SERVER_URLS, ETHEREUM_LIKE_ASSETS } from '@exodus/ethereum-lib'
2
2
 
3
3
  import { create } from './api'
4
+ import ClarityServer from './clarity'
4
5
 
5
6
  const serverMap = Object.fromEntries(
6
7
  ETHEREUM_LIKE_ASSETS.map((assetName) => [
7
8
  assetName,
8
- create(DEFAULT_SERVER_URLS[assetName], assetName),
9
+ DEFAULT_SERVER_URLS[assetName].includes('clarity')
10
+ ? new ClarityServer({ baseAssetName: assetName, uri: DEFAULT_SERVER_URLS[assetName] })
11
+ : create(DEFAULT_SERVER_URLS[assetName], assetName),
9
12
  ])
10
13
  )
11
14
 
@@ -0,0 +1,327 @@
1
+ import BN from 'bn.js'
2
+ import { BaseMonitor } from '@exodus/asset-lib'
3
+ import { getAssetAddresses, isRpcBalanceAsset } from '@exodus/ethereum-lib'
4
+ import { isEmpty } from 'lodash'
5
+
6
+ import {
7
+ type PendingTransactionsDictionary,
8
+ getAllLogItemsByAsset,
9
+ checkPendingTransactions,
10
+ getDeriveTransactionsToCheck,
11
+ } from './monitor-utils'
12
+ import { getLogItemsFromServerTx, getDeriveDataNeededForTick, filterEffects } from './clarity-utils'
13
+
14
+ import { type Tx } from '@exodus/models'
15
+
16
+ type DerivedData = {
17
+ ourWalletAddress: string,
18
+ currentAccountState: Object,
19
+ unconfirmedTransactions: PendingTransactionsDictionary,
20
+ pendingTransactionsGroupedByAddressAndNonce: PendingTransactionsDictionary,
21
+ simulatedTransactions: any,
22
+ }
23
+
24
+ export class ClarityMonitor extends BaseMonitor {
25
+ constructor({ server, config, ...args }) {
26
+ super(args)
27
+ this.config = { GAS_PRICE_FROM_WEBSOCKET: true, ...config }
28
+ this.server = server
29
+ this.getAllLogItemsByAsset = getAllLogItemsByAsset
30
+ this.deriveDataNeededForTick = getDeriveDataNeededForTick(this.aci)
31
+ this.deriveTransactionsToCheck = getDeriveTransactionsToCheck({
32
+ getTxLog: (...args) => this.aci.getTxLog(...args),
33
+ })
34
+ this.addHook('before-start', (...args) => this.beforeStart(...args))
35
+ this.addHook('after-stop', (...args) => this.afterStop(...args))
36
+ }
37
+
38
+ setServer(config) {
39
+ if (config.server === this.server.uri) {
40
+ return
41
+ }
42
+ this.server.setURI(config.server)
43
+ this.subscribeWalletAddresses()
44
+ if (this.config.GAS_PRICE_FROM_WEBSOCKET) {
45
+ this.server.connectFee()
46
+ }
47
+ }
48
+
49
+ async deriveData({ assetName, walletAccount, tokens }: Object): DerivedData {
50
+ const { ourWalletAddress, currentAccountState } = await this.deriveDataNeededForTick({
51
+ assetName,
52
+ walletAccount,
53
+ })
54
+ const transactionsToCheck = await this.deriveTransactionsToCheck({
55
+ assetName,
56
+ walletAccount,
57
+ tokens,
58
+ ourWalletAddress,
59
+ })
60
+
61
+ return {
62
+ ourWalletAddress,
63
+ currentAccountState,
64
+ ...transactionsToCheck,
65
+ }
66
+ }
67
+
68
+ // eslint-disable-next-line no-undef
69
+ async checkPendingTransactions(params): { txsToRemove: { tx: Tx, assetSource: AssetSource }[] } {
70
+ const {
71
+ pendingTransactionsToCheck,
72
+ pendingTransactionsGroupedByAddressAndNonce,
73
+ } = checkPendingTransactions(params)
74
+ const txsToRemove = []
75
+ const { walletAccount } = params
76
+
77
+ const updateTx = (tx, asset, { error, remove }) => {
78
+ if (remove) {
79
+ txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
80
+ } else {
81
+ params.logItemsByAsset[asset].push({
82
+ ...tx,
83
+ dropped: true,
84
+ error,
85
+ })
86
+ }
87
+
88
+ // in case this is an ETH fee tx that has associated ERC20 send txs
89
+ const promises = tx.tokens.map(async (assetName) => {
90
+ const tokenTxSet = await this.aci.getTxLog({ assetName, walletAccount })
91
+ if (remove) {
92
+ txsToRemove.push({
93
+ tx: tokenTxSet.get(tx.txId),
94
+ assetSource: { asset: assetName, walletAccount },
95
+ })
96
+ } else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
97
+ params.logItemsByAsset[assetName].push({
98
+ ...tokenTxSet.get(tx.txId),
99
+ error,
100
+ dropped: true,
101
+ })
102
+ }
103
+ })
104
+ return Promise.all(promises)
105
+ }
106
+
107
+ for (const { tx, assetName, replaced = false } of Object.values(
108
+ pendingTransactionsGroupedByAddressAndNonce
109
+ )) {
110
+ if (replaced) {
111
+ await updateTx(tx, assetName, { remove: true })
112
+ delete pendingTransactionsToCheck[tx.txId]
113
+ }
114
+ }
115
+
116
+ for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
117
+ if (params.refresh) await updateTx(tx, assetName, { remove: true })
118
+ else await updateTx(tx, assetName, { error: 'Dropped' })
119
+ }
120
+
121
+ return { txsToRemove }
122
+ }
123
+
124
+ async tick({ walletAccount, refresh }) {
125
+ await this.subscribeWalletAddresses()
126
+
127
+ const assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
128
+ const tokens = Object.values(assets).filter((asset) => asset.baseAsset.name !== asset.name)
129
+ const tokensByAddress = tokens.reduce((map, token) => {
130
+ const addresses = getAssetAddresses(token)
131
+ for (const address of addresses) map.set(address.toLowerCase(), token)
132
+ return map
133
+ }, new Map())
134
+ const assetName = this.asset.name
135
+
136
+ const derivedData = await this.deriveData({ assetName, walletAccount, tokens })
137
+ const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
138
+ const allTxs = [...response.transactions.pending, ...response.transactions.confirmed]
139
+ const hasNewTxs = allTxs.length > 0
140
+
141
+ const logItemsByAsset = this.getAllLogItemsByAsset({
142
+ getLogItemsFromServerTx,
143
+ ourWalletAddress: derivedData.ourWalletAddress,
144
+ allTransactionsFromServer: allTxs,
145
+ asset: this.asset,
146
+ tokensByAddress,
147
+ assets,
148
+ })
149
+
150
+ const { txsToRemove } = await this.checkPendingTransactions({
151
+ txlist: allTxs,
152
+ walletAccount,
153
+ refresh,
154
+ logItemsByAsset,
155
+ asset: this.asset,
156
+ ...derivedData,
157
+ })
158
+
159
+ const nextCursorBuff = response.cursor ? Buffer.from(response.cursor) : null
160
+ const nextCursorBase64 = nextCursorBuff?.toString?.('base64') || null
161
+
162
+ const accountState = await this.getNewAccountState({
163
+ tokens,
164
+ ourWalletAddress: derivedData.ourWalletAddress,
165
+ })
166
+ await this.updateAccountState({
167
+ walletAccount,
168
+ newData: { cursor: nextCursorBase64, ...accountState },
169
+ })
170
+
171
+ await this.removeFromTxLog(txsToRemove)
172
+ await this.updateTxLogByAsset({ logItemsByAsset, walletAccount, refresh })
173
+ if (refresh || hasNewTxs) {
174
+ const unknownTokenAddresses = this._getUnknownTokenAddresses({
175
+ transactions: allTxs,
176
+ tokensByAddress,
177
+ })
178
+ if (unknownTokenAddresses.length > 0) {
179
+ this.emit('unknown-tokens', unknownTokenAddresses)
180
+ }
181
+ }
182
+ }
183
+
184
+ async deriveDataNeededForTick({ assetName, walletAccount }) {
185
+ const receiveAddress = await this.aci.getReceiveAddress({ assetName, walletAccount })
186
+ const currentAccountState = await this.aci.getAccountState({ assetName, walletAccount })
187
+ return {
188
+ ourWalletAddress: receiveAddress.toLowerCase(),
189
+ currentAccountState,
190
+ }
191
+ }
192
+
193
+ async getNewAccountState({ tokens, ourWalletAddress }) {
194
+ const asset = this.asset
195
+ const newAccountState = {}
196
+ const balances = await this.getBalances({ tokens, ourWalletAddress })
197
+ if (isRpcBalanceAsset(asset)) {
198
+ const balance = balances[asset.name]
199
+ newAccountState.balance = asset.currency.baseUnit(balance)
200
+ }
201
+ const tokenBalancePairs = Object.entries(balances).filter((entry) => entry[0] !== asset.name)
202
+ const entries = tokenBalancePairs
203
+ .map((pair) => {
204
+ const token = tokens.find((token) => token.name === pair[0])
205
+ const value = token.currency.baseUnit(pair[1] || 0)
206
+ return value.isZero ? null : [token.name, value]
207
+ })
208
+ .filter((pair) => pair)
209
+ const tokenBalances = Object.fromEntries(entries)
210
+ if (!isEmpty(tokenBalances)) newAccountState.tokenBalances = tokenBalances
211
+ return newAccountState
212
+ }
213
+
214
+ async getReceiveAddressesByWalletAccount() {
215
+ const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
216
+ const addressesByAccount = {}
217
+ for (const walletAccount of walletAccounts) {
218
+ addressesByAccount[walletAccount] = await this.aci.getReceiveAddresses({
219
+ assetName: this.asset.name,
220
+ walletAccount,
221
+ })
222
+ }
223
+ return addressesByAccount
224
+ }
225
+
226
+ async subscribeWalletAddresses() {
227
+ const addressesByWalletAccount = await this.getReceiveAddressesByWalletAccount()
228
+ Object.entries(addressesByWalletAccount).forEach(([walletAccount, addresses]) => {
229
+ const address = String(Array.from(addresses)[0]).toLowerCase() // Only check m/0/0
230
+ this.server.connectTransactions({ walletAccount, address })
231
+ })
232
+ }
233
+
234
+ async getBalances({ tokens, ourWalletAddress }) {
235
+ const batch = {}
236
+ if (isRpcBalanceAsset(this.asset)) {
237
+ const request = this.server.getBalanceRequest(ourWalletAddress)
238
+ batch[this.asset.name] = request
239
+ }
240
+ for (const token of tokens) {
241
+ if (isRpcBalanceAsset(token) && token.contract.address) {
242
+ const request = this.server.balanceOfRequest(ourWalletAddress, token.contract.address)
243
+ batch[token.name] = request
244
+ }
245
+ }
246
+ const pairs = Object.entries(batch)
247
+ if (!pairs.length) {
248
+ return {}
249
+ }
250
+ const requests = pairs.map((pair) => pair[1])
251
+ const responses = await this.server.sendBatchRequest(requests)
252
+ const entries = pairs.map((pair, idx) => {
253
+ const balanceHex = responses[idx]
254
+ const name = pair[0]
255
+ const hex = balanceHex.startsWith('0x') ? balanceHex.slice(2) : balanceHex
256
+ const balance = new BN(hex, 'hex').toString()
257
+ return [name, balance]
258
+ })
259
+ return Object.fromEntries(entries)
260
+ }
261
+
262
+ _getUnknownTokenAddresses({ transactions, tokensByAddress }) {
263
+ const set = transactions.reduce((acc, txn) => {
264
+ const transfers = filterEffects(txn.effects, 'erc20') || []
265
+ transfers.forEach((transfer) => {
266
+ const addr = transfer.address.toLowerCase()
267
+ if (!tokensByAddress.has(addr)) {
268
+ acc.add(addr)
269
+ }
270
+ }, acc)
271
+ return acc
272
+ }, new Set())
273
+ return Array.from(set)
274
+ }
275
+
276
+ async updateGasPrice({ gasPrice, baseFeePerGas }) {
277
+ try {
278
+ const gasPriceHex = gasPrice?.startsWith('0x') ? gasPrice.slice(2) : gasPrice
279
+ const feeConfig = { gasPrice: `${new BN(gasPriceHex, 'hex').toString()} wei` }
280
+ if (baseFeePerGas) {
281
+ const baseFeePerGasHex = baseFeePerGas?.startsWith('0x')
282
+ ? baseFeePerGas.slice(2)
283
+ : baseFeePerGas
284
+ feeConfig.baseFeePerGas = `${new BN(baseFeePerGasHex, 'hex').toString()} wei`
285
+ }
286
+
287
+ this.logger.debug(
288
+ `Update ${this.asset.name} gas price: ${feeConfig.gasPrice}, baseFeePerGas: ${feeConfig.baseFeePerGas}`
289
+ )
290
+ await this.aci.updateFeeConfig({ assetName: this.asset.name, feeConfig })
291
+ } catch (e) {
292
+ this.logger.warn('error updating gasPrice', e)
293
+ }
294
+ }
295
+
296
+ async onTransaction({ walletAccount }) {
297
+ return this.tick({ walletAccount })
298
+ }
299
+
300
+ async onFeeUpdated(fee) {
301
+ return this.updateGasPrice(fee)
302
+ }
303
+
304
+ async beforeStart() {
305
+ this.listenToServerEvents()
306
+ if (this.config.GAS_PRICE_FROM_WEBSOCKET) {
307
+ this.server.connectFee()
308
+ }
309
+ return this.subscribeWalletAddresses()
310
+ }
311
+
312
+ async afterStop() {
313
+ this.server.dispose()
314
+ }
315
+
316
+ async getHistoryFromServer({ walletAccount, derivedData, refresh }) {
317
+ const address = derivedData.ourWalletAddress
318
+ const cursorBase64 = derivedData.currentAccountState?.cursor
319
+ const cursor = cursorBase64 && !refresh ? Buffer.from(cursorBase64, 'base64') : null
320
+ return this.server.getAllTransactions({ walletAccount, address, cursor })
321
+ }
322
+
323
+ listenToServerEvents() {
324
+ this.server.on('transaction', (...args) => this.onTransaction(...args))
325
+ this.server.on('feeUpdated', (...args) => this.onFeeUpdated(...args))
326
+ }
327
+ }
@@ -0,0 +1,3 @@
1
+ export default function filterEffects(effects, type) {
2
+ return effects?.filter((eff) => eff.effect === type)
3
+ }
@@ -0,0 +1,12 @@
1
+ // A super-selector that returns all the current data needed for a tick of the ETH monitor.
2
+
3
+ export default function getDeriveDataNeededForTick(aci) {
4
+ return async function({ assetName, walletAccount }) {
5
+ const receiveAddress = await aci.getReceiveAddress({ assetName, walletAccount })
6
+ const currentAccountState = await aci.getAccountState({ assetName, walletAccount })
7
+ return {
8
+ ourWalletAddress: receiveAddress.toLowerCase(),
9
+ currentAccountState,
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,143 @@
1
+ import getValueOfTransfers from '../monitor-utils/get-value-of-transfers'
2
+ import getTransfersByTokenName from '../monitor-utils/get-transfers-by-token-name'
3
+ import getFeeAmount from '../monitor-utils/get-fee-amount'
4
+ import isConsideredSentTokenTx from '../monitor-utils/is-considered-sent-token-tx'
5
+ import isConfirmedServerTx from '../monitor-utils/is-confirmed-server-tx'
6
+ import getNamesOfTokensTransferredByServerTx from './get-names-of-tokens-transferred-by-server-tx'
7
+ import filterEffects from './filter-effects'
8
+ import lodash from 'lodash'
9
+
10
+ // This function takes a server transaction object fetched from magnifier,
11
+ // and transforms it into Tx models to update the exodus state.
12
+
13
+ export default function getLogItemsFromServerTx({
14
+ serverTx,
15
+ asset,
16
+ ourWalletAddress,
17
+ tokensByAddress,
18
+ assets,
19
+ }) {
20
+ const confirmations = isConfirmedServerTx(serverTx) ? 1 : 0
21
+ const date = parseServerTxDate(serverTx.timestamp) // included even for unconfirmed txs
22
+ const txId = serverTx.hash
23
+ const nonce = parseInt(serverTx.nonce, 16)
24
+ const gasLimit = parseInt(serverTx.gas, 16)
25
+ const error = serverTx.error || (serverTx.status === '0' ? 'Failed' : null)
26
+ const feeAmount = getFeeAmount(asset, serverTx)
27
+ const internalTransfers = filterEffects(serverTx.effects, 'internal') || []
28
+ const ethereumTransfers = [serverTx, ...internalTransfers]
29
+ const erc20Transfers = filterEffects(serverTx.effects, 'erc20') || []
30
+ const tokenTransfersByTokenName = getTransfersByTokenName(erc20Transfers, tokensByAddress)
31
+ const toAddress = tryFindExternalRecipient(ethereumTransfers, ourWalletAddress)
32
+ const ourWalletWasSender = serverTx.from === ourWalletAddress
33
+
34
+ const logItemCommonProperties = {
35
+ confirmations,
36
+ date,
37
+ error,
38
+ txId,
39
+ dropped: false,
40
+ }
41
+
42
+ const logItemsForServerTxEntries = []
43
+
44
+ {
45
+ const sendingTransferPresent = ethereumTransfers.some(({ from }) => from === ourWalletAddress)
46
+ const receivingTransferPresent = ethereumTransfers.some(({ to }) => to === ourWalletAddress)
47
+
48
+ if (sendingTransferPresent || receivingTransferPresent) {
49
+ const coinAmount = getValueOfTransfers(ourWalletAddress, asset, ethereumTransfers)
50
+ const selfSend = isSelfSendTx({
51
+ coinAmount,
52
+ ourWalletWasSender,
53
+ sendingTransferPresent,
54
+ receivingTransferPresent,
55
+ })
56
+ logItemsForServerTxEntries.push([
57
+ asset.name,
58
+ {
59
+ ...logItemCommonProperties,
60
+ coinAmount,
61
+ coinName: asset.name,
62
+ data: {
63
+ data: serverTx.data || '0x',
64
+ nonce,
65
+ gasLimit,
66
+ },
67
+ from: ourWalletWasSender ? [] : [serverTx.from],
68
+ to: ourWalletWasSender ? toAddress : undefined,
69
+ feeAmount: ourWalletWasSender ? feeAmount : undefined,
70
+ selfSend,
71
+ tokens: getNamesOfTokensTransferredByServerTx({
72
+ asset,
73
+ tokensByAddress,
74
+ ourWalletAddress,
75
+ serverTx,
76
+ internalTransfers,
77
+ erc20Transfers,
78
+ }),
79
+ },
80
+ ])
81
+ }
82
+ }
83
+ // handle erc20
84
+ Object.entries(tokenTransfersByTokenName).forEach(([tokenName, tokenTransfers]) => {
85
+ const sendingTransferPresent = tokenTransfers.some(({ from }) => from === ourWalletAddress)
86
+ const receivingTransferPresent = tokenTransfers.some(({ to }) => to === ourWalletAddress)
87
+ if (!sendingTransferPresent && !receivingTransferPresent) return
88
+
89
+ const token = assets[tokenName]
90
+ const tokenTransferToAddress = tryFindExternalRecipient(tokenTransfers, ourWalletAddress)
91
+
92
+ const coinAmount = getValueOfTransfers(ourWalletAddress, token, tokenTransfers)
93
+ const tokenFromAddresses = lodash.uniq(
94
+ tokenTransfers.filter(({ to }) => to === ourWalletAddress).map(({ from }) => from)
95
+ )
96
+
97
+ const isConsideredSent = isConsideredSentTokenTx({
98
+ coinAmount,
99
+ ourWalletWasSender,
100
+ sendingTransferPresent,
101
+ receivingTransferPresent,
102
+ })
103
+ const selfSend = isSelfSendTx({
104
+ coinAmount,
105
+ ourWalletWasSender,
106
+ sendingTransferPresent,
107
+ receivingTransferPresent,
108
+ })
109
+
110
+ logItemsForServerTxEntries.push([
111
+ tokenName,
112
+ {
113
+ ...logItemCommonProperties,
114
+ coinAmount,
115
+ coinName: tokenName,
116
+ data: { nonce, gasLimit },
117
+ from: isConsideredSent ? [] : tokenFromAddresses,
118
+ to: isConsideredSent ? tokenTransferToAddress : undefined,
119
+ feeAmount: isConsideredSent ? feeAmount : undefined,
120
+ selfSend,
121
+ },
122
+ ])
123
+ })
124
+
125
+ return Object.fromEntries(logItemsForServerTxEntries)
126
+ }
127
+
128
+ const tryFindExternalRecipient = (transfers, ourWalletAddress) =>
129
+ transfers.find(({ to }) => to !== ourWalletAddress)?.to || ourWalletAddress
130
+
131
+ // If the timestamp is in the future, use the current time.
132
+ const parseServerTxDate = (timestamp) => new Date(Math.min(Date.now(), parseInt(timestamp, 16)))
133
+
134
+ function isSelfSendTx({
135
+ coinAmount,
136
+ ourWalletWasSender,
137
+ sendingTransferPresent,
138
+ receivingTransferPresent,
139
+ }) {
140
+ return (
141
+ coinAmount.isZero && sendingTransferPresent && receivingTransferPresent && ourWalletWasSender
142
+ )
143
+ }
@@ -0,0 +1,41 @@
1
+ import lodash from 'lodash'
2
+ import getValueOfTransfers from '../monitor-utils/get-value-of-transfers'
3
+ import { FEE_PAYMENT_PREFIX } from '@exodus/ethereum-lib'
4
+
5
+ // For ETH transactions, we store an array of token names inside
6
+ // the TX model. This array is used to display text in the UI for
7
+ // transactions which pay fees for ERC20 token transfers. This
8
+ // function is used to determine that array, choosing which
9
+ // tokens a transaction ostensibly 'paid fees' for.
10
+
11
+ export default function getNamesOfTokensTransferredByServerTx({
12
+ asset,
13
+ tokensByAddress,
14
+ ourWalletAddress,
15
+ serverTx,
16
+ internalTransfers,
17
+ erc20Transfers,
18
+ }) {
19
+ // Treat smart contract ETH transfer transactions as ETH transfers, not token transfers
20
+ if (!getValueOfTransfers(ourWalletAddress, asset, internalTransfers).isZero) {
21
+ return []
22
+ }
23
+
24
+ const tokenAddresses = erc20Transfers.map((event) => event.address)
25
+ if (
26
+ tokenAddresses.length === 0 &&
27
+ serverTx.data &&
28
+ serverTx.data !== '0x' &&
29
+ !serverTx.data.startsWith(FEE_PAYMENT_PREFIX)
30
+ ) {
31
+ // We may still be transacting with tokens even though we found no `tokenAddresses`. This includes
32
+ // failed token transactions as well as other transactions sent to a token contract. If we do not
33
+ // recognize the `to` address in the next step, then we will not treat it as a token contract address.
34
+ tokenAddresses.push(serverTx.to)
35
+ }
36
+
37
+ return lodash
38
+ .uniq(tokenAddresses)
39
+ .map((address) => tokensByAddress.get(address)?.name)
40
+ .filter(Boolean)
41
+ }
@@ -0,0 +1,3 @@
1
+ export { default as getDeriveDataNeededForTick } from './get-derive-data-needed-for-tick'
2
+ export { default as getLogItemsFromServerTx } from './get-log-items-from-server-tx'
3
+ export { default as filterEffects } from './filter-effects'
@@ -7,6 +7,7 @@ import {
7
7
  type PendingTransactionsDictionary,
8
8
  checkPendingTransactions,
9
9
  getAllLogItemsByAsset,
10
+ getLogItemsFromServerTx,
10
11
  getDeriveDataNeededForTick,
11
12
  getDeriveTransactionsToCheck,
12
13
  getHistoryFromServer,
@@ -165,6 +166,7 @@ export class EthereumMonitor extends BaseMonitor {
165
166
  const hasNewIndex = !derivedIndex || index > derivedIndex
166
167
 
167
168
  const logItemsByAsset = getAllLogItemsByAsset({
169
+ getLogItemsFromServerTx,
168
170
  ourWalletAddress: derivedData.ourWalletAddress,
169
171
  allTransactionsFromServer,
170
172
  asset: this.asset,
@@ -1 +1,2 @@
1
1
  export * from './ethereum-monitor'
2
+ export { ClarityMonitor } from './clarity-monitor'
@@ -1,10 +1,9 @@
1
- import getLogItemsFromServerTx from './get-log-items-from-server-tx'
2
-
3
1
  // Iterates through an array of server transactions and formats them into log item
4
2
  // objects (in Tx class fields format). It returns an object of arrays of log
5
3
  // items, keyed by asset name.
6
4
 
7
5
  export default function getAllLogItemsByAsset({
6
+ getLogItemsFromServerTx,
8
7
  allTransactionsFromServer,
9
8
  ourWalletAddress,
10
9
  asset,
@@ -1,5 +1,6 @@
1
1
  export { default as getDeriveDataNeededForTick } from './get-derive-data-needed-for-tick'
2
2
  export { default as getAllLogItemsByAsset } from './get-all-log-items-by-asset'
3
+ export { default as getLogItemsFromServerTx } from './get-log-items-from-server-tx'
3
4
  export { default as getHistoryFromServer } from './get-history-from-server'
4
5
  export { default as checkPendingTransactions } from './check-pending-transactions'
5
6
  export { default as getDeriveTransactionsToCheck } from './get-derive-transactions-to-check'