@exodus/ethereum-api 8.45.5 → 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,26 @@
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
+
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)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: remove meta boilerplate (#6305)
23
+
24
+
25
+
6
26
  ## [8.45.5](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.45.4...@exodus/ethereum-api@8.45.5) (2025-08-19)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.45.5",
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",
@@ -21,7 +21,7 @@
21
21
  "lint:fix": "yarn lint --fix"
22
22
  },
23
23
  "dependencies": {
24
- "@exodus/asset": "^2.0.1",
24
+ "@exodus/asset": "^2.0.4",
25
25
  "@exodus/asset-lib": "^5.3.0",
26
26
  "@exodus/assets": "^11.4.0",
27
27
  "@exodus/basic-utils": "^3.0.1",
@@ -29,8 +29,8 @@
29
29
  "@exodus/crypto": "^1.0.0-rc.13",
30
30
  "@exodus/currency": "^6.0.1",
31
31
  "@exodus/ethereum-lib": "^5.17.0",
32
- "@exodus/ethereum-meta": "^2.9.0",
33
- "@exodus/ethereumholesky-meta": "^2.0.2",
32
+ "@exodus/ethereum-meta": "^2.9.1",
33
+ "@exodus/ethereumholesky-meta": "^2.0.5",
34
34
  "@exodus/ethereumjs": "^1.0.0",
35
35
  "@exodus/fetch": "^1.3.0",
36
36
  "@exodus/models": "^12.13.0",
@@ -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",
@@ -50,11 +51,11 @@
50
51
  },
51
52
  "devDependencies": {
52
53
  "@exodus/assets-testing": "^1.0.0",
53
- "@exodus/bsc-meta": "^2.1.2",
54
- "@exodus/ethereumarbone-meta": "^2.0.3",
55
- "@exodus/fantommainnet-meta": "^2.0.2",
56
- "@exodus/matic-meta": "^2.2.6",
57
- "@exodus/rootstock-meta": "^2.0.3"
54
+ "@exodus/bsc-meta": "^2.5.1",
55
+ "@exodus/ethereumarbone-meta": "^2.1.2",
56
+ "@exodus/fantommainnet-meta": "^2.0.5",
57
+ "@exodus/matic-meta": "^2.2.7",
58
+ "@exodus/rootstock-meta": "^2.0.5"
58
59
  },
59
60
  "bugs": {
60
61
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Aethereum-api"
@@ -63,5 +64,5 @@
63
64
  "type": "git",
64
65
  "url": "git+https://github.com/ExodusMovement/assets.git"
65
66
  },
66
- "gitHead": "4a8fe143d5a361b7abe599a6c36cf0b4ea7457ee"
67
+ "gitHead": "9d7bd1606fa42f6e05c68e0396d5aed7f7ea8852"
67
68
  }
@@ -31,7 +31,6 @@ export const createAssetPluginFactory = (config) => {
31
31
  assetId: token.addresses.current.toLowerCase(),
32
32
  assetType: tokenType,
33
33
  contract: token.addresses,
34
- gasLimit: meta.gasLimit || 120e3,
35
34
  })
36
35
 
37
36
  const blockExplorer = {
@@ -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,
@@ -128,10 +128,6 @@ export const createAssetFactory = ({
128
128
 
129
129
  const server = createEvmServer({ assetName: asset.name, serverUrl, monitorType })
130
130
 
131
- const gasLimit = 21e3 // 21 KGas, enough only for sending ether to normal address
132
-
133
- const contractGasLimit = 1e6 // used when estimateGas fail
134
-
135
131
  const address = {
136
132
  validate: validateFactory({ chainId, useEip1191ChainIdChecksum }),
137
133
  hasChecksum,
@@ -237,7 +233,7 @@ export const createAssetFactory = ({
237
233
  ? estimateL1DataFeeFactory({ l1GasOracleAddress, server })
238
234
  : undefined
239
235
 
240
- const originalGetFee = getFeeFactory({ gasLimit })
236
+ const originalGetFee = getFeeFactory()
241
237
 
242
238
  const getFee = l1GasOracleAddress
243
239
  ? getL1GetFeeFactory({ asset, originalGetFee })
@@ -262,7 +258,7 @@ export const createAssetFactory = ({
262
258
  getBalanceForAddress: createGetBalanceForAddress({ asset, server }),
263
259
  getConfirmationsNumber: () => confirmationsNumber,
264
260
  getDefaultAddressPath: () => defaultAddressPath,
265
- getFeeAsync: getFeeAsyncFactory({ assetClientInterface, gasLimit, createTx }),
261
+ getFeeAsync: getFeeAsyncFactory({ assetClientInterface, createTx }),
266
262
  getFee,
267
263
  getFeeData: () => feeData,
268
264
  getKeyIdentifier: createGetKeyIdentifier({
@@ -289,8 +285,6 @@ export const createAssetFactory = ({
289
285
 
290
286
  const fullAsset = {
291
287
  ...asset,
292
- gasLimit,
293
- contractGasLimit,
294
288
  bip44,
295
289
  keys,
296
290
  address,
@@ -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 }
@@ -9,6 +9,8 @@ export const DEFAULT_GAS_LIMIT_MULTIPLIER = 1.29
9
9
  const GAS_PER_NON_ZERO_BYTE = 16
10
10
 
11
11
  export const DEFAULT_CONTRACT_GAS_LIMIT = 1e6
12
+ export const DEFAULT_TOKEN_GAS_LIMIT = 120e3
13
+ export const DEFAULT_GAS_LIMIT = 21_000
12
14
 
13
15
  // HACK: If a recipient address is not defined, we usually fall back to
14
16
  // default address so gas estimation can still complete successfully
@@ -107,6 +109,14 @@ export function resolveDefaultTxInput({ asset, toAddress, amount }) {
107
109
  : '0x'
108
110
  }
109
111
 
112
+ export const defaultGasLimit = ({ asset, txInput }) => {
113
+ const isToken = isEthereumLikeToken(asset)
114
+ return (
115
+ (isToken ? DEFAULT_TOKEN_GAS_LIMIT : DEFAULT_GAS_LIMIT) +
116
+ GAS_PER_NON_ZERO_BYTE * toBuffer(txInput).length
117
+ )
118
+ }
119
+
110
120
  export async function fetchGasLimit({
111
121
  asset,
112
122
  feeData,
@@ -128,13 +138,12 @@ export async function fetchGasLimit({
128
138
  const toAddress = providedToAddress ?? ARBITRARY_ADDRESS
129
139
  const txInput = providedTxInput || resolveDefaultTxInput({ asset, toAddress, amount })
130
140
 
131
- const defaultGasLimit = () => asset.gasLimit + GAS_PER_NON_ZERO_BYTE * toBuffer(txInput).length
141
+ const isToken = isEthereumLikeToken(asset)
132
142
 
133
143
  const isContract = await isContractAddressCached({ asset, address: toAddress })
134
144
 
135
- const isToken = isEthereumLikeToken(asset)
136
145
  if (!isToken && !isContract && !asset.forceGasLimitEstimation) {
137
- return defaultGasLimit()
146
+ return defaultGasLimit({ asset, txInput })
138
147
  }
139
148
 
140
149
  const gasLimitMultiplier = await resolveGasLimitMultiplier({
@@ -173,10 +182,9 @@ export async function fetchGasLimit({
173
182
  console.error('fetchGasLimit error', err)
174
183
 
175
184
  // fallback value for contract case
176
- if (isContract) return asset.contractGasLimit || DEFAULT_CONTRACT_GAS_LIMIT
185
+ if (isContract) return DEFAULT_CONTRACT_GAS_LIMIT
177
186
 
178
- // fallback value for rest cases: token.
179
- return defaultGasLimit()
187
+ return defaultGasLimit({ asset, txInput })
180
188
  }
181
189
  }
182
190
 
package/src/get-fee.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  getNormalizedFeeDataForCustomFee,
6
6
  resolveGasPrice,
7
7
  } from './fee-utils.js'
8
+ import { defaultGasLimit } from './gas-estimation.js'
8
9
 
9
10
  // Move to meta?
10
11
  const taxes = {
@@ -40,8 +41,8 @@ export const getFeeFactoryGasPrices = ({ customFee, feeData }) => {
40
41
  }
41
42
 
42
43
  export const getFeeFactory =
43
- ({ gasLimit: defaultGasLimit }) =>
44
- ({ asset, feeData, customFee, gasLimit: providedGasLimit, amount }) => {
44
+ () =>
45
+ ({ asset, feeData, customFee, txInput, gasLimit: providedGasLimit, amount }) => {
45
46
  const {
46
47
  feeData: { tipGasPrice, eip1559Enabled },
47
48
  gasPrice,
@@ -50,7 +51,7 @@ export const getFeeFactory =
50
51
  feeData,
51
52
  })
52
53
 
53
- const gasLimit = providedGasLimit || asset.gasLimit || defaultGasLimit
54
+ const gasLimit = providedGasLimit ?? defaultGasLimit({ asset, txInput })
54
55
 
55
56
  // When explicitly opting into EIP-1559 transactions,
56
57
  // lock in the `tipGasPrice` we used to compute the fees.
@@ -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
+ }