@exodus/ethereum-api 8.62.1 → 8.62.3

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.62.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.62.2...@exodus/ethereum-api@8.62.3) (2026-01-13)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: enfore sanity checking about clarity string numerics for canonical tx props (#7262)
13
+
14
+
15
+
16
+ ## [8.62.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.62.1...@exodus/ethereum-api@8.62.2) (2026-01-07)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: when using absolute balances, ensure we process the most recent record (#7127)
23
+
24
+
25
+
6
26
  ## [8.62.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.62.0...@exodus/ethereum-api@8.62.1) (2026-01-01)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.62.1",
3
+ "version": "8.62.3",
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",
@@ -67,5 +67,5 @@
67
67
  "type": "git",
68
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
69
  },
70
- "gitHead": "18e9c9aa3976c189bbba24d8cad7c413ddec336d"
70
+ "gitHead": "d1618b4c41ccde490c3e4aaa8dd239c0c5393a7b"
71
71
  }
@@ -5,6 +5,66 @@ import {
5
5
  getUnconfirmedSentBalance,
6
6
  } from '@exodus/asset-lib'
7
7
  import assert from 'minimalistic-assert'
8
+ import ms from 'ms'
9
+
10
+ const DEFAULT_CANONICAL_ABSOLUTE_BALANCE_SEARCH_DEPTH = ms('5m')
11
+
12
+ // Defines whether a `Tx` is an absolute balance
13
+ // node that can be sorted in terms of transaction
14
+ // ordering.
15
+ //
16
+ export const isCanonicalAbsoluteBalanceTx = (tx) =>
17
+ Boolean(
18
+ tx?.data?.balanceChange &&
19
+ typeof tx.data.blockNumber === 'number' &&
20
+ typeof tx.data.transactionIndex === 'number'
21
+ )
22
+
23
+ // Attempts to find the most recent `absoluteBalance` Tx for
24
+ // a given `txLog`.
25
+ //
26
+ // NOTE: Returns `null` if nothing is found.
27
+ // NOTE: Uses `tx.data.blockNumber` and `tx.data.transactionIndex`,
28
+ // which are not guaranteed to exist in older transaction
29
+ // histories.
30
+ export const getLatestCanonicalAbsoluteBalanceTx = ({
31
+ // Due to nondeterministic sorting for records in close
32
+ // proximity to one-another on high TPS chains, the
33
+ // order of the `reversedTxLog` cannot be trusted to be
34
+ // exactly precise since two consecutive blocks may share
35
+ // the same timestamp. Therefore, when finding an absolute
36
+ // balance node, we use the`searchDepthMs` to take into
37
+ // account extra nodes chronologically deeper than what we
38
+ // presume to be the latest to make sure we resolve to the
39
+ // correct node.
40
+ searchDepthMs = DEFAULT_CANONICAL_ABSOLUTE_BALANCE_SEARCH_DEPTH,
41
+ reversedTxLog,
42
+ }) => {
43
+ assert(reversedTxLog, 'expected reversedTxLog')
44
+
45
+ let latest = null
46
+
47
+ for (const tx of reversedTxLog) {
48
+ if (latest) {
49
+ const diff = +latest.date - +tx.date
50
+
51
+ if (diff >= searchDepthMs) break
52
+ }
53
+
54
+ if (!isCanonicalAbsoluteBalanceTx(tx)) continue
55
+
56
+ if (
57
+ !latest ||
58
+ tx.data.blockNumber > latest.data.blockNumber ||
59
+ (tx.data.blockNumber === latest.data.blockNumber &&
60
+ tx.data.transactionIndex > latest.data.transactionIndex)
61
+ ) {
62
+ latest = tx
63
+ }
64
+ }
65
+
66
+ return latest
67
+ }
8
68
 
9
69
  /**
10
70
  * TODO: Balance model gaps for EVM staking assets
@@ -32,7 +92,6 @@ import assert from 'minimalistic-assert'
32
92
  *
33
93
  * See: docs/balances-model.md for the intended balance model spec.
34
94
  */
35
-
36
95
  export const getAbsoluteBalance = ({ asset, txLog }) => {
37
96
  assert(asset, 'asset is required')
38
97
  assert(txLog, 'txLog is required')
@@ -41,13 +100,26 @@ export const getAbsoluteBalance = ({ asset, txLog }) => {
41
100
  return asset.currency.ZERO
42
101
  }
43
102
 
103
+ // NOTE: We reverse the `txLog` to prioritize the handling
104
+ // of the most recent transactions first. The reason
105
+ // we do this is to terminate early if we find an
106
+ // absolute balance node.
107
+ const reversedTxLog = txLog.reverse()
108
+
109
+ const maybeLatestAbsoluteBalanceTx = getLatestCanonicalAbsoluteBalanceTx({ reversedTxLog })
110
+
44
111
  let balance = asset.currency.ZERO
45
112
  let hasAbsoluteBalance = false
46
113
 
47
- const reversedTxLog = txLog.reverse()
48
-
49
114
  for (const tx of reversedTxLog) {
50
115
  if (tx.data.balanceChange) {
116
+ // If we are aware of the existence of the most
117
+ // recent absolute balance, then we can ignore
118
+ // any competing instances.
119
+ if (maybeLatestAbsoluteBalanceTx && tx.txId !== maybeLatestAbsoluteBalanceTx.txId) {
120
+ continue
121
+ }
122
+
51
123
  hasAbsoluteBalance = true
52
124
  balance = balance.add(asset.currency.baseUnit(tx.data.balanceChange.to))
53
125
 
@@ -23,6 +23,14 @@ const MINIMUM_DELEGATION_GAS_LIMIT = 180_000
23
23
 
24
24
  const EXTRA_GAS_LIMIT = 20_000 // extra gas Limit to prevent tx failing if something change on pool state (till tx is in mempool)
25
25
 
26
+ // Estimates based on real life transaction averages.
27
+ // Overestimated averages by 4x.
28
+ const STAKING_OPERATION_FALLBACK_FEE_ESTIMATES = {
29
+ delegate: '0.00081512',
30
+ undelegate: '0.000554',
31
+ claimUndelegatedBalance: '0.0001800912',
32
+ }
33
+
26
34
  const getStakingApi = memoize(
27
35
  (asset) => new EthereumStaking(asset, undefined, asset.server),
28
36
  (asset) => asset.name
@@ -346,61 +354,76 @@ export function createEthereumStakingService({
346
354
 
347
355
  async function estimateDelegateOperation({ walletAccount, operation, args, feeData }) {
348
356
  const asset = await getAsset(assetName)
349
- const staking = getStakingApi(asset)
350
-
351
- const requestedAmount = args.amount
352
- ? amountToCurrency({ asset, amount: args.amount })
353
- : asset.currency.ZERO
354
357
 
355
- let delegatorAddress
356
- ;({ delegatorAddress, feeData } = await getTransactionProps({ feeData, walletAccount }))
357
-
358
- if (operation === 'undelegate') {
359
- return estimateUndelegate({ walletAccount, amount: requestedAmount, feeData })
360
- }
358
+ try {
359
+ const staking = getStakingApi(asset)
361
360
 
362
- const NAMING_MAP = {
363
- delegate: 'stake',
364
- undelegate: 'unstake',
365
- claimUndelegatedBalance: 'claimWithdrawRequest',
366
- }
361
+ const requestedAmount = args.amount
362
+ ? amountToCurrency({ asset, amount: args.amount })
363
+ : asset.currency.ZERO
367
364
 
368
- const delegateOperation = staking[NAMING_MAP[operation]].bind(staking)
365
+ let delegatorAddress
366
+ ;({ delegatorAddress, feeData } = await getTransactionProps({ feeData, walletAccount }))
369
367
 
370
- if (!delegateOperation) throw new Error('Invalid staking operation')
368
+ if (operation === 'undelegate') {
369
+ return estimateUndelegate({ walletAccount, amount: requestedAmount, feeData })
370
+ }
371
371
 
372
- const { amount, data } = await delegateOperation({ ...args, amount: requestedAmount })
372
+ const NAMING_MAP = {
373
+ delegate: 'stake',
374
+ undelegate: 'unstake',
375
+ claimUndelegatedBalance: 'claimWithdrawRequest',
376
+ }
373
377
 
374
- const { fee } = await (operation === 'claimUndelegatedBalance'
375
- ? estimateTxFee({
376
- from: delegatorAddress,
377
- to: staking.accountingAddress,
378
- amount,
379
- txInput: data,
380
- feeData,
381
- })
382
- : // The `gasUsed` of a delegation transaction can vary
383
- // significantly depending upon whether it will result
384
- // in the activation of new slots (i.e. validator creation).
385
- //
386
- // This can result in transaction `revert` due to slippage
387
- // from incompatible transaction ordering.
388
- //
389
- // To mitigate this, we:
390
- // 1. Simulate the delegation at an amplified deposit amount
391
- // to show enhanced fees close to a proximity buffer.
392
- // 2. Originate the transaction from the WETH contract,
393
- // which guarantees deep native ether liquidity which
394
- // exceeds any rational user deposit.
395
- estimateTxFee({
396
- from: WETH9_ADDRESS,
397
- to: staking.poolAddress,
398
- amount: amount.add(asset.currency.defaultUnit(SLOT_ACTIVATION_PROXIMITY_ETH)),
399
- txInput: data,
400
- feeData,
401
- }))
378
+ const delegateOperation = staking[NAMING_MAP[operation]].bind(staking)
379
+
380
+ if (!delegateOperation) throw new Error('Invalid staking operation')
381
+
382
+ const { amount, data } = await delegateOperation({ ...args, amount: requestedAmount })
383
+
384
+ const { fee } = await (operation === 'claimUndelegatedBalance'
385
+ ? estimateTxFee({
386
+ from: delegatorAddress,
387
+ to: staking.accountingAddress,
388
+ amount,
389
+ txInput: data,
390
+ feeData,
391
+ })
392
+ : // The `gasUsed` of a delegation transaction can vary
393
+ // significantly depending upon whether it will result
394
+ // in the activation of new slots (i.e. validator creation).
395
+ //
396
+ // This can result in transaction `revert` due to slippage
397
+ // from incompatible transaction ordering.
398
+ //
399
+ // To mitigate this, we:
400
+ // 1. Simulate the delegation at an amplified deposit amount
401
+ // to show enhanced fees close to a proximity buffer.
402
+ // 2. Originate the transaction from the WETH contract,
403
+ // which guarantees deep native ether liquidity which
404
+ // exceeds any rational user deposit.
405
+ estimateTxFee({
406
+ from: WETH9_ADDRESS,
407
+ to: staking.poolAddress,
408
+ amount: amount.add(asset.currency.defaultUnit(SLOT_ACTIVATION_PROXIMITY_ETH)),
409
+ txInput: data,
410
+ feeData,
411
+ }))
412
+
413
+ return fee
414
+ } catch (error) {
415
+ const fallbackFee = STAKING_OPERATION_FALLBACK_FEE_ESTIMATES[operation]
416
+ if (fallbackFee) {
417
+ console.warn(
418
+ `ETH staking fee estimation failed for '${operation}', using fallback:`,
419
+ error.message
420
+ )
421
+ return asset.currency.defaultUnit(fallbackFee)
422
+ }
402
423
 
403
- return fee
424
+ error.message = `StakingFeeEstimationError: Failed to estimate fee for '${operation}' (and no fallback configured). Cause: ${error.message}`
425
+ throw error
426
+ }
404
427
  }
405
428
 
406
429
  async function estimateTxFee({ from, to, amount, txInput, feeData }) {
@@ -510,7 +533,6 @@ export function createEthereumStakingService({
510
533
  walletAccount,
511
534
  address: to,
512
535
  amount: amount || asset.currency.ZERO,
513
- shouldLog: true,
514
536
  txInput,
515
537
  gasPrice,
516
538
  gasLimit,
@@ -430,7 +430,6 @@ export function createPolygonStakingService({
430
430
  walletAccount,
431
431
  address: to,
432
432
  amount: asset.currency.ZERO,
433
- shouldLog: true,
434
433
  txInput,
435
434
  gasPrice,
436
435
  gasLimit,
@@ -9,9 +9,10 @@ import isConsideredSentTokenTx from '../monitor-utils/is-considered-sent-token-t
9
9
  import filterEffects from './filter-effects.js'
10
10
  import getNamesOfTokensTransferredByServerTx from './get-names-of-tokens-transferred-by-server-tx.js'
11
11
 
12
- // This function takes a server transaction object fetched from magnifier,
13
- // and transforms it into Tx models to update the exodus state.
12
+ const isFiniteInteger = (n) => Number.isFinite(n) && Number.isInteger(n)
14
13
 
14
+ // This function takes a server transaction object fetched from clarity,
15
+ // and transforms it into Tx models to update the exodus state.
15
16
  export default function getLogItemsFromServerTx({
16
17
  serverTx,
17
18
  asset,
@@ -24,6 +25,8 @@ export default function getLogItemsFromServerTx({
24
25
  const txId = serverTx.hash
25
26
  const nonce = parseInt(serverTx.nonce, 10)
26
27
  const gasLimit = parseInt(serverTx.gas, 10)
28
+ const blockNumber = serverTx.blockNumber
29
+ const transactionIndex = parseInt(serverTx.transactionIndex, 10)
27
30
  const error = serverTx.error || (serverTx.status === '0' ? 'Failed' : null)
28
31
  const feeAmount = getFeeAmount(asset, serverTx)
29
32
  const internalTransfers = filterEffects(serverTx.effects, 'internal') || []
@@ -91,6 +94,8 @@ export default function getLogItemsFromServerTx({
91
94
  nonceChange: nonceUpdate,
92
95
  ...methodId,
93
96
  ...(sent?.length > 0 ? { sent } : undefined),
97
+ ...(isFiniteInteger(blockNumber) ? { blockNumber } : null),
98
+ ...(isFiniteInteger(transactionIndex) ? { transactionIndex } : null),
94
99
  },
95
100
  ...(ourWalletWasSender
96
101
  ? { from: [], to: toAddress, feeAmount, feeCoinName: asset.feeAsset.name }
@@ -149,7 +154,15 @@ export default function getLogItemsFromServerTx({
149
154
  ...logItemCommonProperties,
150
155
  coinAmount,
151
156
  coinName: tokenName,
152
- data: { data, nonce, gasLimit, balanceChange, ...methodId },
157
+ data: {
158
+ data,
159
+ nonce,
160
+ gasLimit,
161
+ balanceChange,
162
+ ...methodId,
163
+ ...(isFiniteInteger(blockNumber) ? { blockNumber } : null),
164
+ ...(isFiniteInteger(transactionIndex) ? { transactionIndex } : null),
165
+ },
153
166
  ...(isConsideredSent
154
167
  ? { from: [], to: tokenTransferToAddress, feeAmount, feeCoinName: token.feeAsset.name }
155
168
  : { from: tokenFromAddresses }),
@@ -4,19 +4,17 @@
4
4
  // array is a transfer for that asset. If we sent more than we received, the value is negative.
5
5
 
6
6
  export default function getValueOfTransfers(ourWalletAddress, asset, transfers) {
7
- return transfers
8
- .reduce((balanceDifference, { from, to, value }) => {
9
- const transferAmount = asset.currency.baseUnit(value)
7
+ return transfers.reduce((balanceDifference, { from, to, value }) => {
8
+ const transferAmount = asset.currency.baseUnit(value)
10
9
 
11
- if (from === ourWalletAddress) {
12
- balanceDifference = balanceDifference.sub(transferAmount)
13
- }
10
+ if (from === ourWalletAddress) {
11
+ balanceDifference = balanceDifference.sub(transferAmount)
12
+ }
14
13
 
15
- if (to === ourWalletAddress) {
16
- balanceDifference = balanceDifference.add(transferAmount)
17
- }
14
+ if (to === ourWalletAddress) {
15
+ balanceDifference = balanceDifference.add(transferAmount)
16
+ }
18
17
 
19
- return balanceDifference
20
- }, asset.currency.ZERO)
21
- .to(asset.ticker)
18
+ return balanceDifference
19
+ }, asset.currency.ZERO)
22
20
  }