@exodus/ethereum-api 8.70.2 → 8.71.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,34 @@
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.71.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.70.2...@exodus/ethereum-api@8.71.0) (2026-04-13)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: evm duplex transactions (#7688)
13
+
14
+ * feat: import ethersproject-abi@5 to exodus/ethereumjs (#7626)
15
+
16
+ * feat: surface validator queue times to ethereum staking service (#7438)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: enable duplex transaction bumps and serialize tx.data.data to txLog (#7705)
23
+
24
+ * fix(ethereum-api): use the right ns key for sockets upon disconnection (#7716)
25
+
26
+ * fix: EVM balance updates when clarity history fetch fails (#7652)
27
+
28
+ * fix: remove unsignedTx from getFeeAsync return value (#7164)
29
+
30
+ * fix: route event-driven ticks through tickWalletAccounts in ClarityMonitor (#7711)
31
+
32
+
33
+
6
34
  ## [8.70.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.70.1...@exodus/ethereum-api@8.70.2) (2026-03-27)
7
35
 
8
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.70.2",
3
+ "version": "8.71.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",
@@ -33,7 +33,6 @@
33
33
  "@exodus/ethereum-meta": "^2.9.1",
34
34
  "@exodus/ethereumholesky-meta": "^2.0.5",
35
35
  "@exodus/ethereumjs": "^1.8.0",
36
- "@exodus/ethersproject-abi": "^5.4.2-exodus.2",
37
36
  "@exodus/fetch": "^1.3.0",
38
37
  "@exodus/models": "^13.0.0",
39
38
  "@exodus/safe-string": "^1.4.0",
@@ -69,5 +68,5 @@
69
68
  "type": "git",
70
69
  "url": "git+https://github.com/ExodusMovement/assets.git"
71
70
  },
72
- "gitHead": "959153b1ea9f3fc33398dd5a167c2c29c8af2df1"
71
+ "gitHead": "0d1117298321691db0e13516ec5178fb2b8e1f73"
73
72
  }
@@ -1,5 +1,5 @@
1
1
  import { BlacklistCheckTypes } from '@exodus/asset-lib'
2
- import { defaultAbiCoder } from '@exodus/ethersproject-abi'
2
+ import { defaultAbiCoder } from '@exodus/ethereumjs/ethers5-abi'
3
3
  import { safeString } from '@exodus/safe-string'
4
4
  import assert from 'minimalistic-assert'
5
5
  import ms from 'ms'
@@ -258,6 +258,11 @@ export const createAssetFactory = ({
258
258
  createTx,
259
259
  })
260
260
 
261
+ const getFeeAsync = async (...args) => {
262
+ const { unsignedTx, ...rest } = await createTx(...args)
263
+ return rest
264
+ }
265
+
261
266
  const estimateL1DataFee = l1GasOracleAddress
262
267
  ? estimateL1DataFeeFactory({ l1GasOracleAddress, server })
263
268
  : undefined
@@ -291,7 +296,7 @@ export const createAssetFactory = ({
291
296
  ...(getBlackListStatus && { getBlackListStatus }),
292
297
  getConfirmationsNumber: () => confirmationsNumber,
293
298
  getDefaultAddressPath: () => defaultAddressPath,
294
- getFeeAsync: createTx, // createTx alias, remove me when possible
299
+ getFeeAsync, // createTx alias, remove me when possible
295
300
  getFee,
296
301
  getFeeData: () => feeData,
297
302
  getKeyIdentifier: createGetKeyIdentifier({
@@ -79,11 +79,11 @@ export default class ClarityServer extends EventEmitter {
79
79
  }
80
80
 
81
81
  disconnectRpc() {
82
- this.disconnectSocket(this.rpcNamespace)
82
+ this.disconnectSocket(this.formatRpcNamespace())
83
83
  }
84
84
 
85
85
  disconnectFee() {
86
- this.disconnectSocket(this.feeNamespace)
86
+ this.disconnectSocket(this.formatFeeNamespace())
87
87
  }
88
88
 
89
89
  disconnectSocket(namespace) {
@@ -113,6 +113,7 @@ export async function fetchGasLimit({
113
113
  fromAddress: providedFromAddress,
114
114
  toAddress: providedToAddress,
115
115
  txInput: providedTxInput,
116
+ txValue: providedTxValue,
116
117
  amount: providedAmount,
117
118
  contractAddress: providedTxToAddress,
118
119
  bip70,
@@ -133,6 +134,7 @@ export async function fetchGasLimit({
133
134
  toAddress: providedToAddress,
134
135
  txToAddress: providedTxToAddress,
135
136
  txInput: providedTxInput,
137
+ txValue: providedTxValue,
136
138
  txType,
137
139
  })
138
140
 
package/src/get-fee.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  calculateBumpedGasPrice,
3
3
  calculateBumpedGasPriceForFeeData,
4
4
  calculateExtraEth,
5
+ isEthereumLikeToken,
5
6
  } from '@exodus/ethereum-lib'
6
7
  import assert from 'minimalistic-assert'
7
8
 
@@ -17,7 +18,11 @@ const taxes = {
17
18
  paxgold: 0.0002,
18
19
  }
19
20
 
20
- export const getExtraFeeData = ({ asset, amount }) => {
21
+ export const getExtraFeeData = ({ asset, amount, txValue }) => {
22
+ if (isEthereumLikeToken(asset) && txValue?.gt(asset.baseAsset.currency.ZERO)) {
23
+ return { type: 'tax', extraFee: txValue }
24
+ }
25
+
21
26
  const tax = taxes[asset.name]
22
27
  if (!amount || !tax || amount.isZero) {
23
28
  return {}
@@ -87,7 +92,7 @@ export const getAggregateTransactionPricing = ({ baseAsset, customFee, feeData,
87
92
 
88
93
  export const getFeeFactory =
89
94
  () =>
90
- ({ asset, feeData, customFee, txInput, gasLimit: providedGasLimit, amount }) => {
95
+ ({ asset, feeData, customFee, txInput, gasLimit: providedGasLimit, amount, txValue }) => {
91
96
  const {
92
97
  feeData: { tipGasPrice, eip1559Enabled },
93
98
  gasPrice,
@@ -103,7 +108,7 @@ export const getFeeFactory =
103
108
  // lock in the `tipGasPrice` we used to compute the fees.
104
109
  const maybeReturnTipGasPrice = eip1559Enabled ? { tipGasPrice } : null
105
110
 
106
- const extraFeeData = getExtraFeeData({ asset, amount })
111
+ const extraFeeData = getExtraFeeData({ asset, amount, txValue })
107
112
 
108
113
  const fee = gasPrice.mul(gasLimit)
109
114
  return { ...maybeReturnTipGasPrice, fee, gasPrice, extraFeeData }
@@ -0,0 +1,53 @@
1
+ import { fetch as exodusFetch } from '@exodus/fetch'
2
+ import { TraceId } from '@exodus/traceparent'
3
+ import assert from 'minimalistic-assert'
4
+
5
+ const BASE_URL = 'https://eth-clarity.a.exodus.io/api/v2/ethereum/proxy/everstake/'
6
+
7
+ const fetch = async (path, config = Object.create(null)) => {
8
+ const url = new URL(path, BASE_URL).toString()
9
+
10
+ const response = await exodusFetch(url, config)
11
+
12
+ const newErrorWithTrace = (msg) => {
13
+ const error = new Error(msg)
14
+ error.traceId = TraceId.fromResponse(response)
15
+ return error
16
+ }
17
+
18
+ if (!response.ok) throw newErrorWithTrace(`failed to fetch ${path}`)
19
+
20
+ const data = await response.json()
21
+
22
+ if (!data || typeof data !== 'object') throw newErrorWithTrace('malformed response')
23
+
24
+ return data
25
+ }
26
+
27
+ const isFiniteInteger = (e) => Number.isInteger(e) && Number.isFinite(e)
28
+
29
+ export const getEverstakeValidatorsQueue = async () => {
30
+ // https://swagger.eth-api-b2c.everstake.one/#/Staking/validatorsQueue
31
+ const result = await fetch('v1/validators/queue')
32
+
33
+ const {
34
+ // Estimated time in seconds of delay before validator become active
35
+ validator_activation_time: validatorActivationTime,
36
+ // Estimated time in seconds of delay before validator become exited
37
+ validator_exit_time: validatorExitTime,
38
+ // Withdraw period in seconds from Beacon chain
39
+ validator_withdraw_time: validatorWithdrawTime,
40
+ } = result
41
+
42
+ assert(isFiniteInteger(validatorActivationTime), 'expected integer validatorActivationTime')
43
+ assert(isFiniteInteger(validatorExitTime), 'expected integer validatorExitTime')
44
+ assert(isFiniteInteger(validatorWithdrawTime), 'expected integer validatorWithdrawTime')
45
+
46
+ // NOTE: We convert the values into milliseconds for
47
+ // normalized handling on clients.
48
+ return {
49
+ validatorActivationTime: validatorActivationTime * 1000,
50
+ validatorExitTime: validatorExitTime * 1000,
51
+ validatorWithdrawTime: validatorWithdrawTime * 1000,
52
+ }
53
+ }
@@ -6,6 +6,7 @@ import { createWatchTx as defaultCreateWatch } from '../../watch-tx.js'
6
6
  import { stakingProviderClientFactory } from '../staking-provider-client.js'
7
7
  import { amountToCurrency, DISABLE_BALANCE_CHECKS, resolveFeeData } from '../utils/index.js'
8
8
  import { EthereumStaking } from './api.js'
9
+ import { getEverstakeValidatorsQueue } from './everstake.js'
9
10
 
10
11
  const WETH9_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
11
12
 
@@ -579,12 +580,14 @@ export async function getEthereumStakingInfo({ address, asset, server }) {
579
580
  pendingDepositedBalance,
580
581
  withdrawRequest,
581
582
  rewardsBalance,
583
+ everstakeValidatorsQueue,
582
584
  ] = await Promise.all([
583
585
  staking.autocompoundBalanceOf(delegator),
584
586
  staking.pendingBalanceOf(delegator),
585
587
  staking.pendingDepositedBalanceOf(delegator),
586
588
  staking.withdrawRequest(delegator),
587
589
  staking.getTotalRewards(delegator),
590
+ getEverstakeValidatorsQueue().catch(() => null),
588
591
  ])
589
592
 
590
593
  const delegatedBalance = activeStakedBalance.add(pendingBalance).add(pendingDepositedBalance)
@@ -614,5 +617,8 @@ export async function getEthereumStakingInfo({ address, asset, server }) {
614
617
  unclaimedUndelegatedBalance,
615
618
  canClaimUndelegatedBalance,
616
619
  isUndelegateInProgress,
620
+ validatorActivationTime: everstakeValidatorsQueue?.validatorActivationTime,
621
+ validatorExitTime: everstakeValidatorsQueue?.validatorExitTime,
622
+ validatorWithdrawTime: everstakeValidatorsQueue?.validatorWithdrawTime,
617
623
  }
618
624
  }
package/src/tx-create.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ assertValidTxValue,
2
3
  currency2buffer,
3
4
  getHighestIncentiveTxByNonce,
4
5
  isEthereumLikeToken,
@@ -85,7 +86,7 @@ async function createUnsignedTxWithFees({
85
86
  : asset.baseAsset.currency.ZERO
86
87
 
87
88
  const fee = baseFee.add(l1DataFee)
88
- const extraFeeData = getExtraFeeData({ asset, amount })
89
+ const extraFeeData = getExtraFeeData({ asset, amount, txValue })
89
90
  const unsignedTx = {
90
91
  txData: { transactionBuffer, chainId },
91
92
  txMeta: {
@@ -262,8 +263,15 @@ const createBumpUnsignedTx = async ({
262
263
 
263
264
  const amount = (replacedTokenTx || replacedTx).coinAmount.negate()
264
265
 
265
- const txInput = replacedTokenTx ? null : replacedTx.data.data || '0x'
266
+ const txValue = assertValidTxValue({
267
+ asset,
268
+ amount,
269
+ txValue: replacedTx.coinAmount.negate(),
270
+ })
271
+
272
+ const isDuplex = Boolean(replacedTokenTx && txValue.gt(baseAsset.currency.ZERO))
266
273
 
274
+ const txInput = replacedTokenTx && !isDuplex ? null : replacedTx.data.data || '0x'
267
275
  const replacedTxNonce = replacedTx.data.nonce
268
276
 
269
277
  assert(
@@ -291,6 +299,7 @@ const createBumpUnsignedTx = async ({
291
299
  txInput,
292
300
  toAddress,
293
301
  txType,
302
+ txValue,
294
303
  walletAccount,
295
304
  })
296
305
 
@@ -345,6 +354,8 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
345
354
  address, // same as toAddress
346
355
  txInput: providedTxInput, // Provided when swapping via a DEX contract
347
356
  txType = TX_TYPE_TRANSFER, // Defines what kind of transaction is being performed.
357
+ txValue: providedTxValue, // Override the native ETH value (e.g. payable contract calls with txInput when specifying a token asset)
358
+ contractAddress: providedTxToAddress, // Controls the final target address of the transaction.
348
359
  gasLimit: providedGasLimit, // Provided by exchange when known
349
360
  amount: providedAmount, // The NU amount to be sent, to be included in the tx value or tx input
350
361
  nonce: providedNonce,
@@ -377,6 +388,8 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
377
388
  })
378
389
 
379
390
  if (bumpTxId) {
391
+ assert(!providedTxValue && !providedTxToAddress)
392
+
380
393
  return createBumpUnsignedTx({
381
394
  chainId,
382
395
  asset,
@@ -422,6 +435,8 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
422
435
  })
423
436
 
424
437
  if (nft) {
438
+ assert(!providedTxValue && !providedTxToAddress)
439
+
425
440
  const {
426
441
  contractAddress: txToAddress,
427
442
  gasLimit,
@@ -464,7 +479,9 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
464
479
  nonce,
465
480
  txInput: providedTxInput,
466
481
  toAddress: providedToAddress,
482
+ txToAddress: providedTxToAddress,
467
483
  txType,
484
+ txValue: providedTxValue,
468
485
  walletAccount,
469
486
  })
470
487
 
@@ -478,6 +495,7 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
478
495
  fromAddress,
479
496
  toAddress: resolvedTxAttributes.toAddress,
480
497
  txInput: resolvedTxAttributes.txInput,
498
+ txValue: resolvedTxAttributes.txValue,
481
499
  contractAddress: resolvedTxAttributes.txToAddress,
482
500
  bip70,
483
501
  amount: resolvedTxAttributes.amount,
@@ -173,29 +173,103 @@ export class ClarityMonitorV2 extends BaseMonitor {
173
173
  }
174
174
 
175
175
  const { derivedData, tokensByAddress, assets, tokens, assetName } = walletAccountInfo
176
-
177
- const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
178
-
179
- const { allTxs } = await normalizeTransactionsResponse({
180
- asset: this.asset,
181
- fromAddress: derivedData.ourWalletAddress,
182
- response,
183
- walletAccount,
184
- })
185
-
186
- const cursor = response.cursor
187
-
188
- await this.processAndFillTransactionsToState({
189
- allTxs,
176
+ const { accountState, eip7702Delegation, isBlacklisted } = await this.getStateUpdate({
190
177
  derivedData,
191
- tokensByAddress,
192
- assets,
193
178
  tokens,
194
- assetName,
195
179
  walletAccount,
196
- refresh,
197
- cursor,
198
180
  })
181
+ const batch = this.aci.createOperationsBatch()
182
+ const newData = {
183
+ ...accountState,
184
+ ...(isBlacklisted !== undefined && { isBlacklisted }),
185
+ ...(eip7702Delegation !== undefined && { eip7702Delegation }),
186
+ }
187
+ let allTxs = []
188
+ let hasNewTxs = false
189
+ let historyError
190
+
191
+ try {
192
+ const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
193
+
194
+ ;({ allTxs } = await normalizeTransactionsResponse({
195
+ asset: this.asset,
196
+ fromAddress: derivedData.ourWalletAddress,
197
+ response,
198
+ walletAccount,
199
+ }))
200
+
201
+ hasNewTxs = allTxs.length > 0
202
+
203
+ const logItemsByAsset = this.getAllLogItemsByAsset({
204
+ getLogItemsFromServerTx,
205
+ ourWalletAddress: derivedData.ourWalletAddress,
206
+ allTransactionsFromServer: allTxs,
207
+ asset: this.asset,
208
+ tokensByAddress,
209
+ assets,
210
+ })
211
+
212
+ const { txsToRemove } = await this.checkPendingTransactions({
213
+ txlist: allTxs,
214
+ walletAccount,
215
+ refresh,
216
+ logItemsByAsset,
217
+ asset: this.asset,
218
+ ...derivedData,
219
+ })
220
+
221
+ this.aci.removeTxLogBatch({
222
+ assetName,
223
+ walletAccount,
224
+ txs: txsToRemove,
225
+ batch,
226
+ })
227
+
228
+ for (const [assetName, txs] of Object.entries(logItemsByAsset)) {
229
+ this.aci.updateTxLogAndNotifyBatch({
230
+ assetName,
231
+ walletAccount,
232
+ txs,
233
+ refresh,
234
+ batch,
235
+ })
236
+ }
237
+
238
+ if (response.cursor) {
239
+ newData.clarityCursor = response.cursor
240
+ }
241
+ } catch (error) {
242
+ historyError = error
243
+ }
244
+
245
+ try {
246
+ this.aci.updateAccountStateBatch({
247
+ assetName,
248
+ walletAccount,
249
+ accountState,
250
+ newData,
251
+ batch,
252
+ })
253
+
254
+ await this.aci.executeOperationsBatch(batch)
255
+ } catch (batchError) {
256
+ if (!historyError) throw batchError
257
+ this.logger.warn('error persisting account state after history failure', batchError)
258
+ }
259
+
260
+ if (historyError) {
261
+ throw historyError
262
+ }
263
+
264
+ if (refresh || hasNewTxs) {
265
+ const unknownTokenAddresses = this.getUnknownTokenAddresses({
266
+ transactions: allTxs,
267
+ tokensByAddress,
268
+ })
269
+ if (unknownTokenAddresses.length > 0) {
270
+ this.emit('unknown-tokens', unknownTokenAddresses)
271
+ }
272
+ }
199
273
  }
200
274
 
201
275
  async processAndFillTransactionsToState({
@@ -209,7 +283,6 @@ export class ClarityMonitorV2 extends BaseMonitor {
209
283
  refresh,
210
284
  cursor,
211
285
  }) {
212
- const shouldCheckBlacklist = this.tickCount[walletAccount] === 0
213
286
  const hasNewTxs = allTxs.length > 0
214
287
 
215
288
  const logItemsByAsset = this.getAllLogItemsByAsset({
@@ -230,27 +303,11 @@ export class ClarityMonitorV2 extends BaseMonitor {
230
303
  ...derivedData,
231
304
  })
232
305
 
233
- const accountState = await this.getNewAccountState({
306
+ const { accountState, eip7702Delegation, isBlacklisted } = await this.getStateUpdate({
307
+ derivedData,
234
308
  tokens,
235
- currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
236
- ourWalletAddress: derivedData.ourWalletAddress,
237
- })
238
-
239
- const eip7702Delegation = await getCurrentEIP7702Delegation({
240
- server: this.server,
241
- address: derivedData.ourWalletAddress,
242
- eip7702Supported: this.eip7702Supported,
243
- currentDelegation: derivedData.currentAccountState?.eip7702Delegation,
244
- logger: this.logger,
309
+ walletAccount,
245
310
  })
246
- const isBlacklisted = shouldCheckBlacklist
247
- ? await getCurrentBlackListStatus({
248
- getBlackListStatus: this.getBlackListStatus,
249
- address: derivedData.ourWalletAddress,
250
- currentIsBlacklisted: derivedData.currentAccountState?.isBlacklisted,
251
- logger: this.logger,
252
- })
253
- : undefined
254
311
 
255
312
  const batch = this.aci.createOperationsBatch()
256
313
 
@@ -303,6 +360,33 @@ export class ClarityMonitorV2 extends BaseMonitor {
303
360
  }
304
361
  }
305
362
 
363
+ async getStateUpdate({ derivedData, tokens, walletAccount }) {
364
+ const shouldCheckBlacklist = this.tickCount[walletAccount] === 0
365
+ const accountState = await this.getNewAccountState({
366
+ tokens,
367
+ currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
368
+ ourWalletAddress: derivedData.ourWalletAddress,
369
+ })
370
+
371
+ const eip7702Delegation = await getCurrentEIP7702Delegation({
372
+ server: this.server,
373
+ address: derivedData.ourWalletAddress,
374
+ eip7702Supported: this.eip7702Supported,
375
+ currentDelegation: derivedData.currentAccountState?.eip7702Delegation,
376
+ logger: this.logger,
377
+ })
378
+ const isBlacklisted = shouldCheckBlacklist
379
+ ? await getCurrentBlackListStatus({
380
+ getBlackListStatus: this.getBlackListStatus,
381
+ address: derivedData.ourWalletAddress,
382
+ currentIsBlacklisted: derivedData.currentAccountState?.isBlacklisted,
383
+ logger: this.logger,
384
+ })
385
+ : undefined
386
+
387
+ return { accountState, eip7702Delegation, isBlacklisted }
388
+ }
389
+
306
390
  async addSingleTx({ tx, address, cursor }) {
307
391
  const walletAccounts = this.#walletAccountByAddress.get(address)
308
392
 
@@ -153,9 +153,7 @@ export class ClarityMonitor extends BaseMonitor {
153
153
 
154
154
  async tick({ walletAccount, refresh }) {
155
155
  await this.subscribeWalletAddresses()
156
- // TODO: Investigate routing the onTransaction path through tickWithExtra first,
157
- // so tickCount is initialized and this fallback can be removed in a dedicated follow-up.
158
- const tickCount = this.tickCount[walletAccount] ?? 0
156
+ const tickCount = this.tickCount[walletAccount]
159
157
  const shouldCheckBlacklist = tickCount === 0
160
158
 
161
159
  const assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
@@ -167,35 +165,107 @@ export class ClarityMonitor extends BaseMonitor {
167
165
  }, new Map())
168
166
  const assetName = this.asset.name
169
167
  const derivedData = await this.deriveData({ assetName, walletAccount, tokens })
170
- const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
171
-
172
- const { allTxs } = await normalizeTransactionsResponse({
173
- asset: this.asset,
174
- fromAddress: derivedData.ourWalletAddress,
175
- response,
176
- walletAccount,
168
+ const { accountState, eip7702Delegation, isBlacklisted } = await this.getStateUpdate({
169
+ derivedData,
170
+ tokens,
171
+ shouldCheckBlacklist,
177
172
  })
178
173
 
179
- const hasNewTxs = allTxs.length > 0
174
+ const batch = this.aci.createOperationsBatch()
175
+ const newData = {
176
+ ...accountState,
177
+ ...(isBlacklisted !== undefined && { isBlacklisted }),
178
+ ...(eip7702Delegation !== undefined && { eip7702Delegation }),
179
+ }
180
+ let allTxs = []
181
+ let hasNewTxs = false
182
+ let historyError
180
183
 
181
- const logItemsByAsset = this.getAllLogItemsByAsset({
182
- getLogItemsFromServerTx,
183
- ourWalletAddress: derivedData.ourWalletAddress,
184
- allTransactionsFromServer: allTxs,
185
- asset: this.asset,
186
- tokensByAddress,
187
- assets,
188
- })
184
+ try {
185
+ const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
189
186
 
190
- const { txsToRemove } = await this.checkPendingTransactions({
191
- txlist: allTxs,
192
- walletAccount,
193
- refresh,
194
- logItemsByAsset,
195
- asset: this.asset,
196
- ...derivedData,
197
- })
187
+ ;({ allTxs } = await normalizeTransactionsResponse({
188
+ asset: this.asset,
189
+ fromAddress: derivedData.ourWalletAddress,
190
+ response,
191
+ walletAccount,
192
+ }))
193
+
194
+ hasNewTxs = allTxs.length > 0
195
+
196
+ const logItemsByAsset = this.getAllLogItemsByAsset({
197
+ getLogItemsFromServerTx,
198
+ ourWalletAddress: derivedData.ourWalletAddress,
199
+ allTransactionsFromServer: allTxs,
200
+ asset: this.asset,
201
+ tokensByAddress,
202
+ assets,
203
+ })
204
+
205
+ const { txsToRemove } = await this.checkPendingTransactions({
206
+ txlist: allTxs,
207
+ walletAccount,
208
+ refresh,
209
+ logItemsByAsset,
210
+ asset: this.asset,
211
+ ...derivedData,
212
+ })
213
+
214
+ this.aci.removeTxLogBatch({
215
+ assetName,
216
+ walletAccount,
217
+ txs: txsToRemove,
218
+ batch,
219
+ })
220
+
221
+ for (const [assetName, txs] of Object.entries(logItemsByAsset)) {
222
+ this.aci.updateTxLogAndNotifyBatch({
223
+ assetName,
224
+ walletAccount,
225
+ txs,
226
+ refresh,
227
+ batch,
228
+ })
229
+ }
230
+
231
+ newData.clarityCursor = response.cursor
232
+ } catch (error) {
233
+ historyError = error
234
+ }
235
+
236
+ try {
237
+ // Persist balance and account metadata even when the history path fails
238
+ // during startup bursts or other temporary backend pressure.
239
+ this.aci.updateAccountStateBatch({
240
+ assetName,
241
+ walletAccount,
242
+ accountState,
243
+ newData,
244
+ batch,
245
+ })
246
+
247
+ await this.aci.executeOperationsBatch(batch)
248
+ } catch (batchError) {
249
+ if (!historyError) throw batchError
250
+ this.logger.warn('error persisting account state after history failure', batchError)
251
+ }
252
+
253
+ if (historyError) {
254
+ throw historyError
255
+ }
256
+
257
+ if (refresh || hasNewTxs) {
258
+ const unknownTokenAddresses = this.getUnknownTokenAddresses({
259
+ transactions: allTxs,
260
+ tokensByAddress,
261
+ })
262
+ if (unknownTokenAddresses.length > 0) {
263
+ this.emit('unknown-tokens', unknownTokenAddresses)
264
+ }
265
+ }
266
+ }
198
267
 
268
+ async getStateUpdate({ derivedData, tokens, shouldCheckBlacklist }) {
199
269
  const accountState = await this.getNewAccountState({
200
270
  tokens,
201
271
  currentTokenBalances: derivedData.currentAccountState?.tokenBalances,
@@ -209,6 +279,7 @@ export class ClarityMonitor extends BaseMonitor {
209
279
  currentDelegation: derivedData.currentAccountState?.eip7702Delegation,
210
280
  logger: this.logger,
211
281
  })
282
+
212
283
  const isBlacklisted = shouldCheckBlacklist
213
284
  ? await getCurrentBlackListStatus({
214
285
  getBlackListStatus: this.getBlackListStatus,
@@ -218,52 +289,7 @@ export class ClarityMonitor extends BaseMonitor {
218
289
  })
219
290
  : undefined
220
291
 
221
- const batch = this.aci.createOperationsBatch()
222
-
223
- this.aci.removeTxLogBatch({
224
- assetName,
225
- walletAccount,
226
- txs: txsToRemove,
227
- batch,
228
- })
229
-
230
- for (const [assetName, txs] of Object.entries(logItemsByAsset)) {
231
- this.aci.updateTxLogAndNotifyBatch({
232
- assetName,
233
- walletAccount,
234
- txs,
235
- refresh,
236
- batch,
237
- })
238
- }
239
-
240
- // All updates must go through newData (accountState param is only used for mem merging)
241
- const newData = {
242
- ...accountState,
243
- clarityCursor: response.cursor,
244
- ...(isBlacklisted !== undefined && { isBlacklisted }),
245
- ...(eip7702Delegation !== undefined && { eip7702Delegation }),
246
- }
247
-
248
- this.aci.updateAccountStateBatch({
249
- assetName,
250
- walletAccount,
251
- accountState,
252
- newData,
253
- batch,
254
- })
255
-
256
- await this.aci.executeOperationsBatch(batch)
257
-
258
- if (refresh || hasNewTxs) {
259
- const unknownTokenAddresses = this.getUnknownTokenAddresses({
260
- transactions: allTxs,
261
- tokensByAddress,
262
- })
263
- if (unknownTokenAddresses.length > 0) {
264
- this.emit('unknown-tokens', unknownTokenAddresses)
265
- }
266
- }
292
+ return { accountState, eip7702Delegation, isBlacklisted }
267
293
  }
268
294
 
269
295
  async getNewAccountState({ tokens, currentTokenBalances, ourWalletAddress }) {
@@ -376,7 +402,7 @@ export class ClarityMonitor extends BaseMonitor {
376
402
  }
377
403
 
378
404
  async onTransaction({ walletAccount }) {
379
- return this.tick({ walletAccount })
405
+ return this.tickWalletAccounts({ walletAccount })
380
406
  }
381
407
 
382
408
  async onFeeUpdated(fee) {
@@ -36,7 +36,7 @@ export const getOptimisticTxLogEffects = async ({
36
36
  // this converts an transactionBuffer to values we can use when creating the tx logs
37
37
  const parsedTx = parseUnsignedTx({ asset, unsignedTx })
38
38
 
39
- const { nonce, to } = parsedTx
39
+ const { nonce, to, txToAddress } = parsedTx
40
40
  assert(Number.isInteger(nonce), 'expected integer nonce')
41
41
 
42
42
  const amount = parsedTx.amount || asset.currency.ZERO
@@ -45,6 +45,9 @@ export const getOptimisticTxLogEffects = async ({
45
45
  const feeAmount = parsedTx.fee
46
46
  assert(feeAmount instanceof NumberUnit, 'expected feeAmount')
47
47
 
48
+ const txValue = parsedTx.value
49
+ assert(txValue instanceof NumberUnit, 'expected txValue')
50
+
48
51
  const maybeTipGasPrice = parsedTx.tipGasPrice
49
52
  if (maybeTipGasPrice) {
50
53
  assert(maybeTipGasPrice instanceof NumberUnit, 'expected NumberUnit tipGasPrice')
@@ -78,7 +81,7 @@ export const getOptimisticTxLogEffects = async ({
78
81
 
79
82
  // Contains effects for smart-contract initiated token movements.
80
83
  const methodOptimisticSideEffects = await operationTxLogSideEffects({
81
- txToAddress: to,
84
+ txToAddress,
82
85
  methodId,
83
86
  asset,
84
87
  walletAccount,
@@ -109,6 +112,7 @@ export const getOptimisticTxLogEffects = async ({
109
112
  ...(maybeTipGasPrice ? { tipGasPrice: maybeTipGasPrice.toBaseString() } : null),
110
113
  ...(methodId ? { methodId } : null),
111
114
  ...(bundleId ? { bundleId } : null),
115
+ ...(data ? { data: bufferToHex(data) } : null),
112
116
  },
113
117
  }
114
118
 
@@ -134,7 +138,7 @@ export const getOptimisticTxLogEffects = async ({
134
138
  txs: [
135
139
  {
136
140
  ...sharedProps,
137
- coinAmount: baseAsset.currency.ZERO,
141
+ coinAmount: selfSend ? baseAsset.currency.ZERO : txValue.abs().negate(),
138
142
  coinName: baseAsset.name,
139
143
  currencies: {
140
144
  [baseAsset.name]: baseAsset.currency,
@@ -1,5 +1,5 @@
1
1
  import NumberUnit from '@exodus/currency'
2
- import { isEthereumLikeToken } from '@exodus/ethereum-lib'
2
+ import { assertValidTxValue, isEthereumLikeToken } from '@exodus/ethereum-lib'
3
3
  import { bufferToHex } from '@exodus/ethereumjs/util'
4
4
  import assert from 'minimalistic-assert'
5
5
 
@@ -154,6 +154,7 @@ export const resolveCriticalTxAttributes = ({
154
154
  toAddress: providedToAddress,
155
155
  txToAddress: providedTxToAddress,
156
156
  txInput: providedTxInput,
157
+ txValue: providedTxValue,
157
158
  txType,
158
159
  }) => {
159
160
  assert(asset, 'expected asset')
@@ -162,6 +163,8 @@ export const resolveCriticalTxAttributes = ({
162
163
  const amount = providedAmount ?? asset.currency.ZERO
163
164
  assert(amount instanceof NumberUnit, 'expected providedAmount')
164
165
 
166
+ const txValue = assertValidTxValue({ asset, amount, txValue: providedTxValue })
167
+
165
168
  if (txType === TX_TYPE_CREATE_CONTRACT) {
166
169
  assert(asset.name === asset.baseAsset.name, 'must use baseAsset for contract deployments')
167
170
  assert(!providedToAddress, 'toAddress must be falsy when creating a contract')
@@ -173,20 +176,28 @@ export const resolveCriticalTxAttributes = ({
173
176
  txInput: providedTxInput,
174
177
  txToAddress: null,
175
178
  txType,
176
- txValue: amount,
179
+ txValue,
177
180
  })
178
181
  }
179
182
 
180
183
  assert(txType === TX_TYPE_TRANSFER, 'expected TX_TYPE_TRANSFER')
181
184
 
185
+ // NOTE: As a helper, if the `toAddress` is omitted, we
186
+ // will automatically assume the recipient of the
187
+ // transaction value to be the `providedTxToAddress`.
188
+ //
189
+ // This means we'll assume the high-level recipient
190
+ // of the assets transferred will be the
191
+ // `providedTxToAddress`, as opposed to another account
192
+ // which would be credited as a side effect.
193
+ //
182
194
  // HACK: If a `toAddress` hasn't been defined, then we
183
195
  // fall back to the `ARBITRARY_ADDRESS`. Note that
184
196
  // this should only be used to help determine the
185
197
  // fee of a transaction where we don't yet know the
186
198
  // intended recipient. Attempts to send to this
187
199
  // sentinel address will ultimately `throw`.
188
- const toAddress = providedToAddress || ARBITRARY_ADDRESS
189
- const txValue = isEthereumLikeToken(asset) ? asset.baseAsset.currency.ZERO : amount
200
+ const toAddress = providedToAddress || providedTxToAddress || ARBITRARY_ADDRESS
190
201
  const txInput = resolveTxInput({ amount, asset, txInput: providedTxInput, toAddress })
191
202
 
192
203
  const baseProps = {
@@ -231,6 +242,7 @@ export const resolveTxAttributesByTxType = async ({
231
242
  txInput: providedTxInput,
232
243
  txToAddress: providedTxToAddress,
233
244
  toAddress: providedToAddress,
245
+ txValue: providedTxValue,
234
246
  txType,
235
247
  walletAccount,
236
248
  }) => {
@@ -260,6 +272,7 @@ export const resolveTxAttributesByTxType = async ({
260
272
  toAddress: providedToAddress,
261
273
  txInput: providedTxInput,
262
274
  txToAddress: providedTxToAddress,
275
+ txValue: providedTxValue,
263
276
  txType,
264
277
  }),
265
278
  })