@exodus/ethereum-api 8.45.6 → 8.46.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/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [8.46.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.45.6...@exodus/ethereum-api@8.46.0) (2025-08-21)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: implement clarity websocket gateway client (#5623)
13
+
14
+
15
+
6
16
  ## [8.45.6](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.45.5...@exodus/ethereum-api@8.45.6) (2025-08-19)
7
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.45.6",
3
+ "version": "8.46.0",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Ethereum and EVM-based blockchains",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -39,6 +39,7 @@
39
39
  "@exodus/web3-ethereum-utils": "^4.2.1",
40
40
  "bn.js": "^5.2.1",
41
41
  "delay": "^4.0.1",
42
+ "eventemitter3": "^4.0.7",
42
43
  "events": "^1.1.1",
43
44
  "idna-uts46-hx": "^2.3.1",
44
45
  "lodash": "^4.17.15",
@@ -63,5 +64,5 @@
63
64
  "type": "git",
64
65
  "url": "git+https://github.com/ExodusMovement/assets.git"
65
66
  },
66
- "gitHead": "41fe883d14291bfb6b73b37b617ce2f7fa914edb"
67
+ "gitHead": "9d7bd1606fa42f6e05c68e0396d5aed7f7ea8852"
67
68
  }
@@ -4,6 +4,7 @@ import ms from 'ms'
4
4
  import { createEvmServer, ValidMonitorTypes } from './exodus-eth-server/index.js'
5
5
  import { createEthereumHooks } from './hooks/index.js'
6
6
  import { ClarityMonitor } from './tx-log/clarity-monitor.js'
7
+ import { ClarityMonitorV2 } from './tx-log/clarity-monitor-v2.js'
7
8
  import { EthereumMonitor } from './tx-log/ethereum-monitor.js'
8
9
  import { EthereumNoHistoryMonitor } from './tx-log/ethereum-no-history-monitor.js'
9
10
  import { resolveNonce } from './tx-send/nonce-utils.js'
@@ -128,6 +129,15 @@ export const createHistoryMonitorFactory = ({
128
129
  ...args,
129
130
  })
130
131
  break
132
+ case 'clarity-v3':
133
+ monitor = new ClarityMonitorV2({
134
+ assetClientInterface,
135
+ interval: ms(monitorInterval || '5m'),
136
+ server,
137
+ rpcBalanceAssetNames,
138
+ ...args,
139
+ })
140
+ break
131
141
  case 'no-history':
132
142
  monitor = new EthereumNoHistoryMonitor({
133
143
  assetClientInterface,
@@ -13,7 +13,7 @@ import ApiCoinNodesServer from './api-coin-nodes.js'
13
13
  import ClarityServer from './clarity.js'
14
14
  import ClarityServerV2 from './clarity-v2.js'
15
15
 
16
- export const ValidMonitorTypes = ['no-history', 'clarity', 'clarity-v2', 'magnifier']
16
+ export const ValidMonitorTypes = ['no-history', 'clarity', 'clarity-v2', 'clarity-v3', 'magnifier']
17
17
 
18
18
  export function createEvmServer({ assetName, serverUrl, monitorType }) {
19
19
  assert(assetName, 'assetName is required')
@@ -25,6 +25,7 @@ export function createEvmServer({ assetName, serverUrl, monitorType }) {
25
25
  case 'clarity':
26
26
  return new ClarityServer({ baseAssetName: assetName, uri: serverUrl })
27
27
  case 'clarity-v2':
28
+ case 'clarity-v3':
28
29
  return new ClarityServerV2({ baseAssetName: assetName, uri: serverUrl })
29
30
  case 'magnifier':
30
31
  return create(serverUrl, assetName)
@@ -0,0 +1,267 @@
1
+ import { createConsoleLogger } from '@exodus/asset-lib'
2
+ import WebSocket from '@exodus/fetch/websocket'
3
+ import EventEmitter from 'eventemitter3'
4
+ import assert from 'minimalistic-assert'
5
+
6
+ const createSubscriptionName = (network) => {
7
+ return `v1/transactions/${network}`
8
+ }
9
+
10
+ const createSubscriptionKey = ({ subscriptionName, address }) => {
11
+ return `${subscriptionName}|${address}`
12
+ }
13
+
14
+ const parseSubscriptionKey = (subscriptionKey) => {
15
+ const [subscriptionName, address] = subscriptionKey.split('|')
16
+
17
+ return {
18
+ subscriptionName,
19
+ subscriptionAddress: address,
20
+ }
21
+ }
22
+
23
+ /**
24
+ * @typedef subscribeWalletAddresses
25
+ * @property {string} network
26
+ * @property {string} address
27
+ */
28
+
29
+ class WsGateway extends EventEmitter {
30
+ #socket = null
31
+ // Dedup subscriptions to reduce workload on server and resubscribe after closing
32
+ #subscriptions = new Set()
33
+ #reconnectionTimeoutId = null
34
+ #defaultUri = 'wss://ws-gateway-clarity.a.exodus.io'
35
+ #uri = null
36
+ #logger = createConsoleLogger(`@exodus/ws-gateway`)
37
+
38
+ _handlers = {
39
+ message: ({ target, data }) => {
40
+ if (target !== this.#socket) return
41
+ const msg = JSON.parse(data)
42
+
43
+ if (Array.isArray(msg)) {
44
+ for (const item of msg) {
45
+ this.#handleMessage(item)
46
+ }
47
+
48
+ return
49
+ }
50
+
51
+ this.#handleMessage(msg)
52
+ },
53
+ close: ({ target, code, reason }) => {
54
+ if (target !== this.#socket) return
55
+ this.#reconnect({ code, reason })
56
+ },
57
+ open: ({ target }) => {
58
+ if (target !== this.#socket) return
59
+ this.emit('opened')
60
+ },
61
+ error: ({ target }) => {
62
+ if (target !== this.#socket) return
63
+ this.#reconnect({ code: 0, reason: 'Error' })
64
+ },
65
+ }
66
+
67
+ getSocket() {
68
+ return this.#socket
69
+ }
70
+
71
+ getSubscriptions() {
72
+ return this.#subscriptions
73
+ }
74
+
75
+ setServer(uri) {
76
+ this.#uri = uri || this.#uri || this.#defaultUri
77
+ }
78
+
79
+ start() {
80
+ if (this.#socket) {
81
+ return
82
+ }
83
+
84
+ this.#socket = new WebSocket(this.#uri)
85
+
86
+ this.#socket.addEventListener('message', this._handlers.message)
87
+ this.#socket.addEventListener('close', this._handlers.close)
88
+ this.#socket.addEventListener('open', this._handlers.open)
89
+ this.#socket.addEventListener('error', this._handlers.error)
90
+ }
91
+
92
+ #reconnect({ code, reason } = {}) {
93
+ this.#logger.warn(`Reconnect. Code ${code}, ${reason || 'Empty reason'}`)
94
+
95
+ this.#clearSocketListeners()
96
+
97
+ if (this.#socket.readyState === WebSocket.CLOSED) this.#socket = null
98
+ clearTimeout(this.#reconnectionTimeoutId)
99
+ this.#subscriptions.clear()
100
+
101
+ this.#reconnectionTimeoutId = setTimeout(() => {
102
+ this.start()
103
+ clearTimeout(this.#reconnectionTimeoutId)
104
+ }, 2500)
105
+ }
106
+
107
+ unsubscribeWalletAddresses({ network, addresses = [] } = {}) {
108
+ assert(network, '"network" is required')
109
+
110
+ const unsubscribeName = createSubscriptionName(network)
111
+
112
+ const payload = []
113
+
114
+ const addressesSet = new Set(addresses)
115
+
116
+ for (const subscriptionKey of this.#subscriptions) {
117
+ const { subscriptionName, subscriptionAddress } = parseSubscriptionKey(subscriptionKey)
118
+ if (subscriptionName !== unsubscribeName) continue
119
+
120
+ // Unsubscribe whole network
121
+ if (addressesSet.size === 0) {
122
+ if (!payload.some((item) => item.subscription === subscriptionName)) {
123
+ payload.push({
124
+ subscription: subscriptionName,
125
+ })
126
+ }
127
+
128
+ this.#subscriptions.delete(subscriptionKey)
129
+ continue
130
+ }
131
+
132
+ if (addressesSet.has(subscriptionAddress)) {
133
+ payload.push({
134
+ subscription: subscriptionName,
135
+ entityId: subscriptionAddress,
136
+ })
137
+ this.#subscriptions.delete(subscriptionKey)
138
+ }
139
+ }
140
+
141
+ if (payload.length === 0) {
142
+ return
143
+ }
144
+
145
+ this.#sendMessage({
146
+ eventName: 'unsubscribe',
147
+ payload,
148
+ })
149
+ }
150
+
151
+ /**
152
+ * @param {Array<subscribeWalletAddresses>} subscriptions
153
+ */
154
+ subscribeWalletAddresses({ network, addresses }) {
155
+ const payload = []
156
+
157
+ for (const address of addresses) {
158
+ const subscriptionName = createSubscriptionName(network)
159
+ const subscriptionKey = createSubscriptionKey({ subscriptionName, address })
160
+
161
+ if (this.#subscriptions.has(subscriptionKey)) {
162
+ continue
163
+ }
164
+
165
+ payload.push({
166
+ subscription: subscriptionName,
167
+ entityId: address,
168
+ })
169
+ }
170
+
171
+ if (payload.length === 0) {
172
+ return
173
+ }
174
+
175
+ this.#sendMessage({
176
+ eventName: 'subscribe',
177
+ payload,
178
+ })
179
+ }
180
+
181
+ #sendMessage(message) {
182
+ if (this.#socket.readyState !== WebSocket.OPEN) {
183
+ return
184
+ }
185
+
186
+ this.#socket.send(JSON.stringify(message))
187
+ }
188
+
189
+ #handleMessage(message) {
190
+ switch (message.type) {
191
+ case 'new_transactions': {
192
+ const { subscription, entityId, payload } = message
193
+ const network = subscription.split('/')[2]
194
+
195
+ for (const transaction of payload.transactions) {
196
+ this.emit(`${network}:new_transaction`, {
197
+ address: entityId,
198
+ transaction,
199
+ cursor: payload.cursor,
200
+ })
201
+ }
202
+
203
+ break
204
+ }
205
+
206
+ case 'connection_ack': {
207
+ this.emit('connected', message)
208
+ break
209
+ }
210
+
211
+ case 'subscribe_ack': {
212
+ const { subscription, entityId } = message
213
+
214
+ const subscriptionKey = createSubscriptionKey({
215
+ subscriptionName: subscription,
216
+ address: entityId,
217
+ })
218
+ this.#subscriptions.add(subscriptionKey)
219
+
220
+ this.emit('subscribed', message)
221
+ break
222
+ }
223
+
224
+ case 'error': {
225
+ this.#logger.error('Error from server', message)
226
+ this.emit('error', message)
227
+ break
228
+ }
229
+
230
+ default: {
231
+ break
232
+ }
233
+ }
234
+ }
235
+
236
+ #clearSocketListeners() {
237
+ if (!this.#socket) return
238
+
239
+ this.#socket.removeEventListener('message', this._handlers.message)
240
+ this.#socket.removeEventListener('close', this._handlers.close)
241
+ this.#socket.removeEventListener('open', this._handlers.open)
242
+ this.#socket.removeEventListener('error', this._handlers.error)
243
+ }
244
+
245
+ dispose(network, addresses) {
246
+ this.unsubscribeWalletAddresses({ network, addresses })
247
+
248
+ if (this.#subscriptions.size > 0) {
249
+ return
250
+ }
251
+
252
+ this.#clearSocketListeners()
253
+
254
+ clearTimeout(this.#reconnectionTimeoutId)
255
+
256
+ if (this.#socket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.#socket.readyState)) {
257
+ this.#socket.close()
258
+ }
259
+
260
+ this.#socket = null
261
+ }
262
+ }
263
+
264
+ const wsGateway = new WsGateway()
265
+ const createWsGateway = () => wsGateway
266
+
267
+ export { createWsGateway, WsGateway }
@@ -0,0 +1,467 @@
1
+ import { BaseMonitor } from '@exodus/asset-lib'
2
+ import { getAssetAddresses } from '@exodus/ethereum-lib'
3
+ import lodash from 'lodash'
4
+
5
+ import { createWsGateway } from '../exodus-eth-server/ws-gateway.js'
6
+ import { executeEthLikeFeeMonitorUpdate } from '../fee-utils.js'
7
+ import { fromHexToString } from '../number-utils.js'
8
+ import { filterEffects, getLogItemsFromServerTx } from './clarity-utils/index.js'
9
+ import {
10
+ checkPendingTransactions,
11
+ excludeUnchangedTokenBalances,
12
+ getAllLogItemsByAsset,
13
+ getDeriveDataNeededForTick,
14
+ getDeriveTransactionsToCheck,
15
+ } from './monitor-utils/index.js'
16
+
17
+ const { isEmpty } = lodash
18
+
19
+ export class ClarityMonitorV2 extends BaseMonitor {
20
+ #wsClient = null
21
+ #walletAccountByAddress = new Map()
22
+ #walletAccountInfo = new Map()
23
+ #rpcBalanceAssetNames = []
24
+ constructor({
25
+ server,
26
+ wsGatewayClient = createWsGateway(),
27
+ rpcBalanceAssetNames,
28
+ config,
29
+ ...args
30
+ } = {}) {
31
+ super(args)
32
+ this.config = { GAS_PRICE_FROM_WEBSOCKET: true, ...config }
33
+ this.server = server
34
+ this.#wsClient = wsGatewayClient
35
+ this.#rpcBalanceAssetNames = rpcBalanceAssetNames
36
+ this.getAllLogItemsByAsset = getAllLogItemsByAsset
37
+ this.deriveDataNeededForTick = getDeriveDataNeededForTick(this.aci)
38
+ this.deriveTransactionsToCheck = getDeriveTransactionsToCheck({
39
+ getTxLog: (...args) => this.aci.getTxLog(...args),
40
+ })
41
+
42
+ this.addHook('before-start', (...args) => this.beforeStart(...args))
43
+ this.addHook('after-stop', (...args) => this.afterStop(...args))
44
+ }
45
+
46
+ setServer(config) {
47
+ const uri = config?.server || this.server.defaultUri
48
+
49
+ this.#wsClient.setServer(config.wsGatewayUrl?.v1)
50
+
51
+ this.#wsClient.on('connected', () => this.subscribeAllWalletAccounts())
52
+ this.#wsClient.start()
53
+
54
+ if (uri === this.server.uri) {
55
+ return
56
+ }
57
+
58
+ this.server.setURI(uri)
59
+ if (this.config.GAS_PRICE_FROM_WEBSOCKET) {
60
+ this.server.connectFee()
61
+ }
62
+ }
63
+
64
+ async deriveData({ assetName, walletAccount, tokens }) {
65
+ const { ourWalletAddress, currentAccountState } = await this.deriveDataNeededForTick({
66
+ assetName,
67
+ walletAccount,
68
+ })
69
+ const transactionsToCheck = await this.deriveTransactionsToCheck({
70
+ assetName,
71
+ walletAccount,
72
+ tokens,
73
+ ourWalletAddress,
74
+ })
75
+
76
+ return {
77
+ ourWalletAddress,
78
+ currentAccountState,
79
+ ...transactionsToCheck,
80
+ }
81
+ }
82
+
83
+ // eslint-disable-next-line no-undef
84
+ async checkPendingTransactions(params) {
85
+ const { pendingTransactionsToCheck, pendingTransactionsGroupedByAddressAndNonce } =
86
+ checkPendingTransactions(params)
87
+ const txsToRemove = []
88
+ const { walletAccount } = params
89
+
90
+ const updateTx = (tx, asset, { error, remove }) => {
91
+ if (remove) {
92
+ txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
93
+ } else {
94
+ params.logItemsByAsset[asset].push({
95
+ ...tx,
96
+ dropped: true,
97
+ error,
98
+ })
99
+ }
100
+
101
+ // in case this is an ETH fee tx that has associated ERC20 send txs
102
+ const promises = tx.tokens.map(async (assetName) => {
103
+ const tokenTxSet = await this.aci.getTxLog({ assetName, walletAccount })
104
+ if (remove) {
105
+ txsToRemove.push({
106
+ tx: tokenTxSet.get(tx.txId),
107
+ assetSource: { asset: assetName, walletAccount },
108
+ })
109
+ } else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
110
+ params.logItemsByAsset[assetName].push({
111
+ ...tokenTxSet.get(tx.txId),
112
+ error,
113
+ dropped: true,
114
+ })
115
+ }
116
+ })
117
+ return Promise.all(promises)
118
+ }
119
+
120
+ for (const { tx, assetName, replaced = false } of Object.values(
121
+ pendingTransactionsGroupedByAddressAndNonce
122
+ )) {
123
+ if (replaced) {
124
+ await updateTx(tx, assetName, { remove: true })
125
+ delete pendingTransactionsToCheck[tx.txId]
126
+ }
127
+ }
128
+
129
+ for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
130
+ if (params.refresh) await updateTx(tx, assetName, { remove: true })
131
+ else await updateTx(tx, assetName, { error: 'Dropped' })
132
+ }
133
+
134
+ return { txsToRemove }
135
+ }
136
+
137
+ async tick({ walletAccount, refresh }) {
138
+ await this.subscribeWalletAddresses(walletAccount)
139
+
140
+ const walletAccountInfo = this.#walletAccountInfo.get(walletAccount)
141
+
142
+ if (!walletAccountInfo) {
143
+ return this.logger.warn('walletAccountInfo is empty', { walletAccount })
144
+ }
145
+
146
+ const { derivedData, tokensByAddress, assets, tokens, assetName } = walletAccountInfo
147
+
148
+ const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
149
+ const allTxs = [...response.transactions.pending, ...response.transactions.confirmed]
150
+ const cursor = response.cursor
151
+
152
+ await this.processAndFillTransactionsToState({
153
+ allTxs,
154
+ derivedData,
155
+ tokensByAddress,
156
+ assets,
157
+ tokens,
158
+ assetName,
159
+ walletAccount,
160
+ refresh,
161
+ cursor,
162
+ })
163
+ }
164
+
165
+ async processAndFillTransactionsToState({
166
+ allTxs,
167
+ derivedData,
168
+ tokensByAddress,
169
+ assets,
170
+ tokens,
171
+ assetName,
172
+ walletAccount,
173
+ refresh,
174
+ cursor,
175
+ }) {
176
+ const hasNewTxs = allTxs.length > 0
177
+
178
+ const logItemsByAsset = this.getAllLogItemsByAsset({
179
+ getLogItemsFromServerTx,
180
+ ourWalletAddress: derivedData.ourWalletAddress,
181
+ allTransactionsFromServer: allTxs,
182
+ asset: this.asset,
183
+ tokensByAddress,
184
+ assets,
185
+ })
186
+
187
+ const { txsToRemove } = await this.checkPendingTransactions({
188
+ txlist: allTxs,
189
+ walletAccount,
190
+ refresh,
191
+ logItemsByAsset,
192
+ asset: this.asset,
193
+ ...derivedData,
194
+ })
195
+
196
+ const accountState = await this.getNewAccountState({
197
+ tokens,
198
+ currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
199
+ ourWalletAddress: derivedData.ourWalletAddress,
200
+ })
201
+
202
+ const batch = this.aci.createOperationsBatch()
203
+
204
+ this.aci.removeTxLogBatch({
205
+ assetName,
206
+ walletAccount,
207
+ txs: txsToRemove,
208
+ batch,
209
+ })
210
+
211
+ for (const [assetName, txs] of Object.entries(logItemsByAsset)) {
212
+ this.aci.updateTxLogAndNotifyBatch({
213
+ assetName,
214
+ walletAccount,
215
+ txs,
216
+ refresh,
217
+ batch,
218
+ })
219
+ }
220
+
221
+ const newData = { ...accountState }
222
+
223
+ if (cursor) {
224
+ newData.clarityCursor = cursor
225
+ }
226
+
227
+ this.aci.updateAccountStateBatch({
228
+ assetName,
229
+ walletAccount,
230
+ accountState,
231
+ newData,
232
+ batch,
233
+ })
234
+
235
+ await this.aci.executeOperationsBatch(batch)
236
+
237
+ if (refresh || hasNewTxs) {
238
+ const unknownTokenAddresses = this.getUnknownTokenAddresses({
239
+ transactions: allTxs,
240
+ tokensByAddress,
241
+ })
242
+ if (unknownTokenAddresses.length > 0) {
243
+ this.emit('unknown-tokens', unknownTokenAddresses)
244
+ }
245
+ }
246
+ }
247
+
248
+ async addSingleTx({ tx, address, cursor }) {
249
+ const walletAccounts = this.#walletAccountByAddress.get(address)
250
+
251
+ if (!walletAccounts || walletAccounts.length === 0) {
252
+ return
253
+ }
254
+
255
+ for (const walletAccount of walletAccounts) {
256
+ const walletAccountInfo = this.#walletAccountInfo.get(walletAccount)
257
+
258
+ if (!walletAccountInfo) {
259
+ continue
260
+ }
261
+
262
+ const { derivedData, tokensByAddress, assets, tokens, assetName } = walletAccountInfo
263
+
264
+ await this.processAndFillTransactionsToState({
265
+ allTxs: [tx],
266
+ derivedData,
267
+ tokensByAddress,
268
+ assets,
269
+ tokens,
270
+ assetName,
271
+ walletAccount,
272
+ refresh: false,
273
+ cursor,
274
+ })
275
+ }
276
+ }
277
+
278
+ async getNewAccountState({ tokens, currentTokenBalances, ourWalletAddress }) {
279
+ const asset = this.asset
280
+ const newAccountState = Object.create(null)
281
+ const balances = await this.getBalances({ tokens, ourWalletAddress })
282
+ if (this.#rpcBalanceAssetNames.includes(asset.name)) {
283
+ const balance = balances[asset.name]
284
+ newAccountState.balance = asset.currency.baseUnit(balance)
285
+ }
286
+
287
+ const tokenBalancePairs = Object.entries(balances).filter((entry) => entry[0] !== asset.name)
288
+ const tokenBalanceEntries = tokenBalancePairs
289
+ .map((pair) => {
290
+ const token = tokens.find((token) => token.name === pair[0])
291
+ const value = token.currency.baseUnit(pair[1] || 0)
292
+ return [token.name, value]
293
+ })
294
+ .filter(Boolean)
295
+
296
+ const tokenBalances = excludeUnchangedTokenBalances(currentTokenBalances, tokenBalanceEntries)
297
+ if (!isEmpty(tokenBalances)) newAccountState.tokenBalances = tokenBalances
298
+ return newAccountState
299
+ }
300
+
301
+ async getReceiveAddressesByWalletAccount() {
302
+ const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
303
+ const addressesByAccount = Object.create(null)
304
+ for (const walletAccount of walletAccounts) {
305
+ addressesByAccount[walletAccount] = await this.aci.getReceiveAddresses({
306
+ assetName: this.asset.name,
307
+ walletAccount,
308
+ useCache: true,
309
+ })
310
+ }
311
+
312
+ return addressesByAccount
313
+ }
314
+
315
+ async fillAssetsTokensAndData({ walletAccount }) {
316
+ const assetName = this.asset.name
317
+ const assets = await this.aci.getAssetsForNetwork({ baseAssetName: assetName })
318
+ const tokens = Object.values(assets).filter((asset) => assetName !== asset.name)
319
+
320
+ const tokensByAddress = tokens.reduce((map, token) => {
321
+ const addresses = getAssetAddresses(token)
322
+ for (const address of addresses) map.set(address.toLowerCase(), token)
323
+ return map
324
+ }, new Map())
325
+
326
+ const derivedData = await this.deriveData({ assetName, walletAccount, tokens })
327
+
328
+ this.#walletAccountInfo.set(walletAccount, {
329
+ assets,
330
+ tokens,
331
+ tokensByAddress,
332
+ derivedData,
333
+ assetName,
334
+ })
335
+ }
336
+
337
+ async subscribeAllWalletAccounts() {
338
+ const addressesByWalletAccount = await this.getReceiveAddressesByWalletAccount()
339
+ const entriesAddressesByWalletAccount = Object.entries(addressesByWalletAccount)
340
+
341
+ for (const [walletAccount] of entriesAddressesByWalletAccount) {
342
+ await this.subscribeWalletAddresses(walletAccount)
343
+ }
344
+ }
345
+
346
+ async subscribeWalletAddresses(walletAccount) {
347
+ const addressesByWalletAccount = await this.aci.getReceiveAddresses({
348
+ assetName: this.asset.name,
349
+ walletAccount,
350
+ useCache: true,
351
+ })
352
+
353
+ const address = addressesByWalletAccount[0].toLowerCase() // Only check m/0/0
354
+ await this.fillAssetsTokensAndData({ walletAccount })
355
+
356
+ if (!this.#walletAccountByAddress.has(address)) {
357
+ this.#walletAccountByAddress.set(address, [])
358
+ }
359
+
360
+ const walletAccounts = this.#walletAccountByAddress.get(address)
361
+
362
+ if (!walletAccounts.includes(walletAccount)) {
363
+ walletAccounts.push(walletAccount)
364
+ this.#walletAccountByAddress.set(address, walletAccounts)
365
+ }
366
+
367
+ this.server.connectTransactions({ walletAccount, address })
368
+
369
+ this.#wsClient.subscribeWalletAddresses({
370
+ network: this.asset.name,
371
+ addresses: [address],
372
+ })
373
+ }
374
+
375
+ async getBalances({ tokens, ourWalletAddress }) {
376
+ const batch = Object.create(null)
377
+ if (this.#rpcBalanceAssetNames.includes(this.asset.name)) {
378
+ const request = this.server.getBalanceRequest(ourWalletAddress)
379
+ batch[this.asset.name] = request
380
+ }
381
+
382
+ for (const token of tokens) {
383
+ if (this.#rpcBalanceAssetNames.includes(token.name) && token.contract.address) {
384
+ const request = this.server.balanceOfRequest(ourWalletAddress, token.contract.address)
385
+ batch[token.name] = request
386
+ }
387
+ }
388
+
389
+ const pairs = Object.entries(batch)
390
+ if (pairs.length === 0) {
391
+ return {}
392
+ }
393
+
394
+ const requests = pairs.map((pair) => pair[1])
395
+ const responses = await this.server.sendBatchRequest(requests)
396
+ const entries = pairs.map((pair, idx) => {
397
+ const balanceHex = responses[idx]
398
+ const name = pair[0]
399
+ const balance = fromHexToString(balanceHex)
400
+ return [name, balance]
401
+ })
402
+ return Object.fromEntries(entries)
403
+ }
404
+
405
+ getUnknownTokenAddresses({ transactions, tokensByAddress }) {
406
+ const set = transactions.reduce((acc, txn) => {
407
+ const transfers = filterEffects(txn.effects, 'erc20') || []
408
+ transfers.forEach((transfer) => {
409
+ const addr = transfer.address.toLowerCase()
410
+ if (!tokensByAddress.has(addr)) {
411
+ acc.add(addr)
412
+ }
413
+ })
414
+ return acc
415
+ }, new Set())
416
+ return [...set]
417
+ }
418
+
419
+ // NOTE: Here, fetchedGasPrices is the result of a call to `ClarityMonitor.getFee()`.
420
+ async updateGasPrice(fetchedGasPrices) {
421
+ try {
422
+ await executeEthLikeFeeMonitorUpdate({
423
+ assetClientInterface: this.aci,
424
+ feeAsset: this.asset,
425
+ fetchedGasPrices,
426
+ })
427
+ } catch (e) {
428
+ this.logger.warn('error updating gasPrice', e)
429
+ }
430
+ }
431
+
432
+ async onFeeUpdated(fee) {
433
+ return this.updateGasPrice(fee)
434
+ }
435
+
436
+ async beforeStart() {
437
+ this.listenToServerEvents()
438
+ if (this.config.GAS_PRICE_FROM_WEBSOCKET) {
439
+ this.server.connectFee()
440
+ }
441
+ }
442
+
443
+ async afterStop() {
444
+ this.server.dispose()
445
+ this.#wsClient.dispose(this.asset.name)
446
+ }
447
+
448
+ async getHistoryFromServer({ walletAccount, derivedData, refresh }) {
449
+ const address = derivedData.ourWalletAddress
450
+ const currentCursor = derivedData.currentAccountState?.clarityCursor
451
+ const cursor = currentCursor && !refresh ? currentCursor : null
452
+ return this.server.getAllTransactions({ walletAccount, address, cursor })
453
+ }
454
+
455
+ listenToServerEvents() {
456
+ this.server.on('feeUpdated', (...args) => this.onFeeUpdated(...args))
457
+ this.#wsClient.on(
458
+ `${this.asset.name}:new_transaction`,
459
+ async ({ transaction, address, cursor }) =>
460
+ this.addSingleTx({
461
+ tx: transaction,
462
+ address,
463
+ cursor,
464
+ })
465
+ )
466
+ }
467
+ }