@exodus/ethereum-api 8.62.2 → 8.62.4

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.4](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.62.3...@exodus/ethereum-api@8.62.4) (2026-01-14)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: absolute canonical nonce (#7238)
13
+
14
+
15
+
16
+ ## [8.62.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.62.2...@exodus/ethereum-api@8.62.3) (2026-01-13)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: enfore sanity checking about clarity string numerics for canonical tx props (#7262)
23
+
24
+
25
+
6
26
  ## [8.62.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.62.1...@exodus/ethereum-api@8.62.2) (2026-01-07)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.62.2",
3
+ "version": "8.62.4",
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": "be4898d14d842c79dfcc327501c390dfaf56157b"
70
+ "gitHead": "138043f1b7f97c9acc7b7b89b0561f26b80757d5"
71
71
  }
@@ -51,7 +51,7 @@ export const createCustomFeesApi = ({ baseAsset }) => {
51
51
  },
52
52
  unit: 'gwei/gas',
53
53
  feeUnitPriceToNumber: (feeNumberUnit) => feeNumberUnit.toNumber(Gwei),
54
- numberToFeeUnitPrice: (value) => new NumberUnit(value, Gwei),
54
+ numberToFeeUnitPrice: (value) => NumberUnit.create(value, Gwei),
55
55
  isEnabled: ({ feeData }) => Boolean(feeData.rbfEnabled),
56
56
  }
57
57
  }
@@ -5,66 +5,8 @@ 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
8
 
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
- }
9
+ import { getLatestCanonicalAbsoluteBalanceTx } from './tx-log/clarity-utils/index.js'
68
10
 
69
11
  /**
70
12
  * TODO: Balance model gaps for EVM staking assets
@@ -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 }) {
@@ -0,0 +1,92 @@
1
+ import assert from 'minimalistic-assert'
2
+ import ms from 'ms'
3
+
4
+ const DEFAULT_CANONICAL_ABSOLUTE_TX_SEARCH_DEPTH = ms('5m')
5
+
6
+ const ABSOLUTE_FIELD_NAME_BALANCE_CHANGE = 'balanceChange'
7
+ const ABSOLUTE_FIELD_NAME_NONCE_CHANGE = 'nonceChange'
8
+
9
+ const ABSOLUTE_BALANCE_FIELDS = new Set([
10
+ ABSOLUTE_FIELD_NAME_BALANCE_CHANGE,
11
+ ABSOLUTE_FIELD_NAME_NONCE_CHANGE,
12
+ ])
13
+
14
+ // Defines whether a `Tx` is an absolute balance
15
+ // node that can be sorted in terms of transaction
16
+ // ordering.
17
+ //
18
+ const isCanonicalAbsoluteTx = (tx, fieldName) => {
19
+ assert(ABSOLUTE_BALANCE_FIELDS.has(fieldName), `unsupported fieldName, "${fieldName}"`)
20
+
21
+ return Boolean(
22
+ tx?.data?.[fieldName] &&
23
+ typeof tx.data.blockNumber === 'number' &&
24
+ typeof tx.data.transactionIndex === 'number'
25
+ )
26
+ }
27
+
28
+ export const isCanonicalAbsoluteBalanceTx = (tx) =>
29
+ isCanonicalAbsoluteTx(tx, ABSOLUTE_FIELD_NAME_BALANCE_CHANGE)
30
+ export const isCanonicalAbsoluteNonceTx = (tx) =>
31
+ isCanonicalAbsoluteTx(tx, ABSOLUTE_FIELD_NAME_NONCE_CHANGE)
32
+
33
+ // Attempts to find the most recent absolute Tx for a given
34
+ // `txLog`.
35
+ //
36
+ // NOTE: Returns `null` if nothing is found.
37
+ // NOTE: Uses `tx.data.blockNumber` and `tx.data.transactionIndex`,
38
+ // which are not guaranteed to exist in older transaction
39
+ // histories.
40
+ const getLatestCanonicalAbsoluteTx = ({
41
+ // Due to nondeterministic sorting for records in close
42
+ // proximity to one-another on high TPS chains, the
43
+ // order of the `reversedTxLog` cannot be trusted to be
44
+ // exactly precise since two consecutive blocks may share
45
+ // the same timestamp. Therefore, when finding an absolute
46
+ // balance node, we use the`searchDepthMs` to take into
47
+ // account extra nodes chronologically deeper than what we
48
+ // presume to be the latest to make sure we resolve to the
49
+ // correct node.
50
+ searchDepthMs = DEFAULT_CANONICAL_ABSOLUTE_TX_SEARCH_DEPTH,
51
+ reversedTxLog,
52
+ fieldName,
53
+ }) => {
54
+ assert(reversedTxLog, 'expected reversedTxLog')
55
+
56
+ let latest = null
57
+
58
+ for (const tx of reversedTxLog) {
59
+ if (latest) {
60
+ const diff = +latest.date - +tx.date
61
+
62
+ if (diff >= searchDepthMs) break
63
+ }
64
+
65
+ if (!isCanonicalAbsoluteTx(tx, fieldName)) continue
66
+
67
+ if (
68
+ !latest ||
69
+ tx.data.blockNumber > latest.data.blockNumber ||
70
+ (tx.data.blockNumber === latest.data.blockNumber &&
71
+ tx.data.transactionIndex > latest.data.transactionIndex)
72
+ ) {
73
+ latest = tx
74
+ }
75
+ }
76
+
77
+ return latest
78
+ }
79
+
80
+ export const getLatestCanonicalAbsoluteBalanceTx = ({ searchDepthMs, reversedTxLog }) =>
81
+ getLatestCanonicalAbsoluteTx({
82
+ searchDepthMs,
83
+ reversedTxLog,
84
+ fieldName: ABSOLUTE_FIELD_NAME_BALANCE_CHANGE,
85
+ })
86
+
87
+ export const getLatestCanonicalAbsoluteNonceTx = ({ searchDepthMs, reversedTxLog }) =>
88
+ getLatestCanonicalAbsoluteTx({
89
+ searchDepthMs,
90
+ reversedTxLog,
91
+ fieldName: ABSOLUTE_FIELD_NAME_NONCE_CHANGE,
92
+ })
@@ -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,
@@ -93,8 +94,8 @@ export default function getLogItemsFromServerTx({
93
94
  nonceChange: nonceUpdate,
94
95
  ...methodId,
95
96
  ...(sent?.length > 0 ? { sent } : undefined),
96
- blockNumber,
97
- transactionIndex,
97
+ ...(isFiniteInteger(blockNumber) ? { blockNumber } : null),
98
+ ...(isFiniteInteger(transactionIndex) ? { transactionIndex } : null),
98
99
  },
99
100
  ...(ourWalletWasSender
100
101
  ? { from: [], to: toAddress, feeAmount, feeCoinName: asset.feeAsset.name }
@@ -159,8 +160,8 @@ export default function getLogItemsFromServerTx({
159
160
  gasLimit,
160
161
  balanceChange,
161
162
  ...methodId,
162
- blockNumber,
163
- transactionIndex,
163
+ ...(isFiniteInteger(blockNumber) ? { blockNumber } : null),
164
+ ...(isFiniteInteger(transactionIndex) ? { transactionIndex } : null),
164
165
  },
165
166
  ...(isConsideredSent
166
167
  ? { from: [], to: tokenTransferToAddress, feeAmount, feeCoinName: token.feeAsset.name }
@@ -1,2 +1,8 @@
1
1
  export { default as getLogItemsFromServerTx } from './get-log-items-from-server-tx.js'
2
2
  export { default as filterEffects } from './filter-effects.js'
3
+ export {
4
+ isCanonicalAbsoluteBalanceTx,
5
+ isCanonicalAbsoluteNonceTx,
6
+ getLatestCanonicalAbsoluteBalanceTx,
7
+ getLatestCanonicalAbsoluteNonceTx,
8
+ } from './absolute.js'
@@ -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
  }
@@ -1,4 +1,7 @@
1
+ import assert from 'minimalistic-assert'
2
+
1
3
  import { getNonce } from '../eth-like-util.js'
4
+ import { getLatestCanonicalAbsoluteNonceTx } from '../tx-log/clarity-utils/absolute.js'
2
5
 
3
6
  export const resolveNonce = async ({
4
7
  asset,
@@ -25,15 +28,31 @@ export const resolveNonce = async ({
25
28
  )
26
29
  }
27
30
 
31
+ const getLatestTxWithNonceChange = ({ reversedTxLog }) => {
32
+ assert(reversedTxLog, 'expected reversedTxLog')
33
+
34
+ const maybeLatestAbsoluteNonceTx = getLatestCanonicalAbsoluteNonceTx({
35
+ reversedTxLog,
36
+ })
37
+
38
+ if (maybeLatestAbsoluteNonceTx) return maybeLatestAbsoluteNonceTx
39
+
40
+ for (const tx of reversedTxLog) {
41
+ if (tx.data.nonceChange) {
42
+ return tx
43
+ }
44
+ }
45
+ }
46
+
28
47
  export const getNonceFromTxLog = ({ txLog, useAbsoluteNonce }) => {
29
48
  let absoluteNonce = 0
49
+
30
50
  if (useAbsoluteNonce) {
31
51
  const reversedTxLog = txLog.reverse()
32
- for (const tx of reversedTxLog) {
33
- if (tx.data.nonceChange) {
34
- absoluteNonce = parseInt(tx.data.nonceChange.to, 10)
35
- break
36
- }
52
+ const maybeLatestTxWithNonceChange = getLatestTxWithNonceChange({ reversedTxLog })
53
+
54
+ if (maybeLatestTxWithNonceChange) {
55
+ absoluteNonce = parseInt(maybeLatestTxWithNonceChange.data.nonceChange.to, 10)
37
56
  }
38
57
  }
39
58