@exodus/ethereum-api 8.79.0 → 8.79.1

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,39 @@
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.79.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.79.1) (2026-09-09)
7
+
8
+
9
+ ### Features
10
+
11
+ * source eip7702 delegation whitelist from remote config ([#8356](https://github.com/ExodusMovement/assets/issues/8356)) ([1a0966b](https://github.com/ExodusMovement/assets/commit/1a0966b08befb6e9310993f323c21d003dea2754))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * disable @exodus/hydra/no-param-reassign-prop-only warnings ([#8531](https://github.com/ExodusMovement/assets/issues/8531)) ([ddae556](https://github.com/ExodusMovement/assets/commit/ddae556361285919fe82cd10a81cfeae79ccc6b5))
17
+ * **ethereum-api:** detect legacy delegate transactions ([#8357](https://github.com/ExodusMovement/assets/issues/8357)) ([070e529](https://github.com/ExodusMovement/assets/commit/070e5295f21f12695e97e721ff8c0601c553e447))
18
+ * **ethereum-api:** preserve live pending txs across refresh to prevent nonce reuse ([#8430](https://github.com/ExodusMovement/assets/issues/8430)) ([a24ad82](https://github.com/ExodusMovement/assets/commit/a24ad82d303ce50606143bc5d1d917fbbad45ef1))
19
+ * **ethereum-api:** preserve precision for wei values in tx simulation ([#7920](https://github.com/ExodusMovement/assets/issues/7920)) ([5c99258](https://github.com/ExodusMovement/assets/commit/5c9925871d450167fcf23e33ba62e100a2d71a9b))
20
+ * **ethereum-api:** reference defined ethereum asset in simulate-tx tests ([#8525](https://github.com/ExodusMovement/assets/issues/8525)) ([27a8918](https://github.com/ExodusMovement/assets/commit/27a8918f5ba3112f1830ac0111a43d30921aa3b3))
21
+ * **ethereum:** prevent silently dropped zero-tip private bundles ([#8493](https://github.com/ExodusMovement/assets/issues/8493)) ([67dd1ff](https://github.com/ExodusMovement/assets/commit/67dd1ffd7e1c22e7119b1781968c4be321ab1822))
22
+ * **lint:** add explicit process imports and enforce no-implicit-process ([#8530](https://github.com/ExodusMovement/assets/issues/8530)) ([885c29d](https://github.com/ExodusMovement/assets/commit/885c29decb1db363673da5be91b2bc6d06da8a6c))
23
+ * **lint:** convert console.log/info to console.debug and enforce no-console ([#8529](https://github.com/ExodusMovement/assets/issues/8529)) ([81cf5e9](https://github.com/ExodusMovement/assets/commit/81cf5e90b8157b084cf4cb311c02803c29e622e4))
24
+ * **lint:** fix import/named warnings and upgrade to error ([#8496](https://github.com/ExodusMovement/assets/issues/8496)) ([8d290c9](https://github.com/ExodusMovement/assets/commit/8d290c948a0d3c2c1897287cb133f9d26cad73b0))
25
+ * **lint:** fix JSDoc warnings across multiple packages ([#8519](https://github.com/ExodusMovement/assets/issues/8519)) ([5fefcd5](https://github.com/ExodusMovement/assets/commit/5fefcd5af996efbe9587cecba540efc7f469ceb2))
26
+ * **lint:** fix no-throw-literal warnings and upgrade to error ([#8498](https://github.com/ExodusMovement/assets/issues/8498)) ([8fc68eb](https://github.com/ExodusMovement/assets/commit/8fc68eb65682e0b5a7da9415410ee1dedf309696))
27
+ * **lint:** fix oxlint warnings and convert rules to error ([#8489](https://github.com/ExodusMovement/assets/issues/8489)) ([19d35be](https://github.com/ExodusMovement/assets/commit/19d35be718391303d331978926884f2f376b356f))
28
+ * **lint:** migrate lodash to @exodus/basic-utils ([#8514](https://github.com/ExodusMovement/assets/issues/8514)) ([12c574f](https://github.com/ExodusMovement/assets/commit/12c574f1ac8ab0592700df574b2643141237090a))
29
+ * **simulation:** route recipient changes to dedicated field, guard asset-less entries ([#8370](https://github.com/ExodusMovement/assets/issues/8370)) ([c0a082b](https://github.com/ExodusMovement/assets/commit/c0a082b6c80710408337ff9285a19fb657c656be))
30
+ * upgrade low-complexity lint rules to error ([#8510](https://github.com/ExodusMovement/assets/issues/8510)) ([25016bd](https://github.com/ExodusMovement/assets/commit/25016bdf43526c6bc9efe2771f92f1ae1ebf7cd7))
31
+
32
+
33
+ ### Performance Improvements
34
+
35
+ * **ethereum:** reduce background staking request volume ([#8617](https://github.com/ExodusMovement/assets/issues/8617)) ([a9746d9](https://github.com/ExodusMovement/assets/commit/a9746d98ee0a2a04f5fd0131d3f1228cbc0aacf2))
36
+
37
+
38
+
6
39
  ## [8.79.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.79.0) (2026-08-18)
7
40
 
8
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.79.0",
3
+ "version": "8.79.1",
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": "85b01e122492dcdd4205ed06df8f4c0af545d25b"
70
+ "gitHead": "53bdb90b8e90413468dc13cf8960ef2aa64dac4b"
71
71
  }
@@ -365,13 +365,13 @@ export const createHistoryMonitorFactory = ({
365
365
  }
366
366
 
367
367
  if (stakingAssetNames.length > 0) {
368
- const afterTickHook = createEthereumHooks({
368
+ const hooks = createEthereumHooks({
369
369
  assetClientInterface,
370
370
  assetName,
371
371
  stakingAssetNames,
372
372
  server,
373
- })['after-tick']
374
- monitor.addHook('after-tick', afterTickHook)
373
+ })
374
+ for (const [name, hook] of Object.entries(hooks)) monitor.addHook(name, hook)
375
375
  }
376
376
 
377
377
  return monitor
@@ -1,3 +1,4 @@
1
+ import { mapValues } from '@exodus/basic-utils'
1
2
  import { createConsoleLogger } from '@exodus/asset-lib'
2
3
  import { ASSET_FAMILY, connectAssetsList } from '@exodus/assets'
3
4
  import bip44Constants from '@exodus/bip44-constants/by-ticker.js'
@@ -18,7 +19,6 @@ import {
18
19
  validateFactory,
19
20
  } from '@exodus/ethereum-lib'
20
21
  import { toChecksumAddress } from '@exodus/ethereumjs/util'
21
- import lodash from 'lodash'
22
22
  import assert from 'minimalistic-assert'
23
23
 
24
24
  import { addressHasHistoryFactory } from './address-has-history.js'
@@ -53,8 +53,6 @@ import { createTxFactory } from './tx-create.js'
53
53
  import { txSendFactory } from './tx-send/index.js'
54
54
  import { createWeb3API } from './web3/index.js'
55
55
 
56
- const { mapValues } = lodash
57
-
58
56
  export const createAssetFactory = ({
59
57
  AccountState: CustomAccountState,
60
58
  assetsList,
@@ -192,6 +190,10 @@ export const createAssetFactory = ({
192
190
  getBalances,
193
191
  })
194
192
 
193
+ const createRpcRequestAccumulator = createRpcRequestAccumulatorFactory({
194
+ multicall3Address,
195
+ })
196
+
195
197
  const { createToken, getTokens } = createTokenFactory(
196
198
  {
197
199
  address,
@@ -204,6 +206,7 @@ export const createAssetFactory = ({
204
206
  server,
205
207
  stakingConfiguration,
206
208
  stakingDependencies,
209
+ createRpcRequestAccumulator,
207
210
  },
208
211
  assets
209
212
  )
@@ -232,10 +235,6 @@ export const createAssetFactory = ({
232
235
  supportsTransactionPrivacy,
233
236
  })
234
237
 
235
- const createRpcRequestAccumulator = createRpcRequestAccumulatorFactory({
236
- multicall3Address,
237
- })
238
-
239
238
  const features = {
240
239
  accountState: true,
241
240
  customTokens,
@@ -262,6 +261,8 @@ export const createAssetFactory = ({
262
261
  const extraData =
263
262
  stakingAssetNames.length > 0
264
263
  ? {
264
+ // Persist ownership independently of host annotations and balances.
265
+ stakingSnapshotOwners: {},
265
266
  staking: mapValues(
266
267
  stakingConfiguration,
267
268
  (assetStakinConfiguration) => assetStakinConfiguration.accountStateExtraData
@@ -43,6 +43,7 @@ const getCreateBaseToken =
43
43
  server,
44
44
  stakingConfiguration = Object.create(null),
45
45
  stakingDependencies = Object.create(null),
46
+ createRpcRequestAccumulator,
46
47
  ...props
47
48
  }) =>
48
49
  ({ name, contract, features, ...tokenDef }) => {
@@ -78,6 +79,7 @@ const getCreateBaseToken =
78
79
  server,
79
80
  stakingConfiguration: stakingConfiguration[name],
80
81
  stakingDependencies: stakingDependencies[name],
82
+ createRpcRequestAccumulator,
81
83
  })
82
84
  }
83
85
 
@@ -1,67 +1,195 @@
1
1
  import assert from 'minimalistic-assert'
2
2
 
3
3
  import processTxLog from '../tx-log-staking-processor/index.js'
4
+ import { getStakingActivity, stakingStateInterval } from './staking-freshness.js'
5
+
6
+ // Service-owned fields must never leak between delegator snapshots. Keep host
7
+ // annotations outside this set; new service fields must be added here as well.
8
+ const STAKING_SNAPSHOT_FIELDS = new Set([
9
+ 'rewardsBalance',
10
+ 'isDelegating',
11
+ 'activeStakedBalance',
12
+ 'delegatedBalance',
13
+ 'pendingBalance',
14
+ 'pendingDepositedBalance',
15
+ 'minDelegateAmount',
16
+ 'unclaimedUndelegatedBalance',
17
+ 'canClaimUndelegatedBalance',
18
+ 'isUndelegateInProgress',
19
+ 'validatorActivationTime',
20
+ 'validatorExitTime',
21
+ 'validatorWithdrawTime',
22
+ 'liquidRewards',
23
+ 'withdrawable',
24
+ 'unbondNonce',
25
+ 'minRewardsToWithdraw',
26
+ ])
4
27
 
5
28
  export const createEthereumHooks = ({
6
29
  assetClientInterface,
7
30
  assetName,
8
31
  stakingAssetNames,
9
32
  server,
33
+ now = () => performance.now(),
10
34
  }) => {
11
35
  assert(assetClientInterface, 'assetClientInterface is required')
12
36
  assert(server, 'server is required')
13
37
  assert(assetName, 'assetName is required')
14
- const afterTickHook = async ({ walletAccount }) => {
15
- // args passed from monitor tick method ({ monitor, error })
16
- const assets = await assetClientInterface.getAssetsForNetwork({
17
- baseAssetName: assetName,
18
- })
19
-
20
- // some assets may not be available
21
- const stakingAssets = stakingAssetNames.map((assetName) => assets[assetName]).filter(Boolean)
22
-
38
+ const freshness = new Map()
39
+ const addresses = new Map()
40
+ const generations = new Map()
41
+ const commits = new Map()
42
+ const afterTickHook = async ({ walletAccount, refresh = false }) => {
43
+ // Order requests at entry, not after address discovery: discovery itself can
44
+ // be delayed while a newer explicit refresh is already running.
45
+ const accountKey = String(walletAccount)
46
+ const generation = {}
47
+ generations.set(accountKey, generation)
48
+ const assets = await assetClientInterface.getAssetsForNetwork({ baseAssetName: assetName })
49
+ const stakingAssets = stakingAssetNames.map((name) => assets[name]).filter(Boolean)
23
50
  const userAddress = await assetClientInterface.getReceiveAddress({
24
51
  assetName,
25
52
  walletAccount,
26
53
  useCache: true,
27
54
  })
28
-
29
- const stakingInfo = {
30
- staking: Object.create(null),
31
- }
55
+ const address = userAddress.toString().toLowerCase()
56
+ if (generations.get(accountKey) !== generation) return
57
+ const addressChanged = addresses.get(accountKey) !== address
58
+ addresses.set(accountKey, address)
59
+ const baseTxLog = await assetClientInterface.getTxLog({ assetName, walletAccount })
60
+ const patches = Object.create(null)
61
+ const completed = []
32
62
 
33
63
  for (const asset of stakingAssets) {
34
64
  const stakingApi = asset.api?.staking
35
65
  if (!stakingApi?.getStakingInfo) continue
66
+ const key = JSON.stringify([String(walletAccount), assetName, asset.name, address])
67
+ const previous = freshness.get(key)
68
+ try {
69
+ const txLog =
70
+ asset.name === assetName
71
+ ? baseTxLog
72
+ : await assetClientInterface.getTxLog({ assetName: asset.name, walletAccount })
73
+ const { pending, fingerprint } = getStakingActivity({
74
+ assetName: asset.name,
75
+ txLogs: [baseTxLog, txLog],
76
+ })
77
+ const force =
78
+ refresh || addressChanged || pending || !previous || previous.fingerprint !== fingerprint
79
+ if (!force && now() < previous.deadline) continue
36
80
 
37
- const assetStakingInfo = await stakingApi.getStakingInfo({
38
- address: userAddress.toString(),
39
- })
40
- stakingInfo.staking[asset.name] = assetStakingInfo
81
+ patches[asset.name] = await stakingApi.getStakingInfo({
82
+ address,
83
+ background: true,
84
+ useCache: !force,
85
+ // Initial per-address discovery is full, but the queue is global.
86
+ useQueueCache:
87
+ !refresh && !pending && (!previous || previous.fingerprint === fingerprint),
88
+ })
89
+ completed.push([
90
+ key,
91
+ { fingerprint, deadline: now() + stakingStateInterval(patches[asset.name]) },
92
+ ])
93
+ } catch (error) {
94
+ // Keep the last successful account state and retry on the next normal
95
+ // tick. Never interpret transport/contract/provider failures as zero.
96
+ if (generations.get(accountKey) === generation) freshness.delete(key)
97
+ console.warn(`Failed to refresh ${asset.name} staking state`, error)
98
+ }
41
99
  }
42
100
 
43
- const batch = assetClientInterface.createOperationsBatch()
44
- assetClientInterface.updateAccountStateBatch({
45
- assetName,
46
- walletAccount,
47
- newData: stakingInfo,
48
- batch,
49
- })
50
-
51
- const processTxLogsPromises = stakingAssets.map((asset) => {
52
- return processTxLog({
53
- asset,
54
- assetClientInterface,
101
+ const publish = async () => {
102
+ if (generations.get(accountKey) !== generation) return
103
+ const batch = assetClientInterface.createOperationsBatch()
104
+ // All asynchronous preparation is inside the per-account commit queue.
105
+ // Newer reads can run concurrently, but cannot commit ahead of an already
106
+ // executing batch. A superseded preparation is discarded before execute.
107
+ const current = await assetClientInterface.getAccountState({ assetName, walletAccount })
108
+ await Promise.all(
109
+ stakingAssets.map((asset) =>
110
+ processTxLog({
111
+ asset,
112
+ assetClientInterface,
113
+ walletAccount,
114
+ batch,
115
+ })
116
+ )
117
+ )
118
+ const latestAddress = await assetClientInterface.getReceiveAddress({
119
+ assetName,
55
120
  walletAccount,
56
- batch,
121
+ useCache: true,
57
122
  })
58
- })
123
+ if (
124
+ latestAddress.toString().toLowerCase() !== address ||
125
+ generations.get(accountKey) !== generation
126
+ ) {
127
+ return
128
+ }
59
129
 
60
- await Promise.all(processTxLogsPromises)
61
- await assetClientInterface.executeOperationsBatch(batch)
130
+ const staking = { ...current.staking }
131
+ const owners = { ...current.stakingSnapshotOwners }
132
+ let changed = false
133
+ // Ownership is persisted with the snapshot, not inferred from in-memory
134
+ // deadlines. Unknown legacy owners cannot supply failed fields on restore.
135
+ // Preserve only non-service host metadata when changing snapshot owners.
136
+ for (const name of new Set([...stakingAssetNames, ...Object.keys(owners)])) {
137
+ if (owners[name]?.address !== address || owners[name]?.network !== assetName) {
138
+ staking[name] = Object.fromEntries(
139
+ Object.entries(staking[name] || {}).filter(([key]) => !STAKING_SNAPSHOT_FIELDS.has(key))
140
+ )
141
+ delete owners[name]
142
+ changed = true
143
+ }
144
+ }
145
+ for (const [name, patch] of Object.entries(patches)) {
146
+ staking[name] = { ...staking[name], ...patch }
147
+ owners[name] = { address, network: assetName }
148
+ changed = true
149
+ }
150
+ if (changed) {
151
+ assetClientInterface.updateAccountStateBatch({
152
+ assetName,
153
+ walletAccount,
154
+ newData: { staking, stakingSnapshotOwners: owners },
155
+ batch,
156
+ })
157
+ }
158
+ // No await between the final generation/address check and batch execution.
159
+ await assetClientInterface.executeOperationsBatch(batch)
160
+ // An in-flight storage commit cannot be cancelled through ACI. It is
161
+ // serialized before newer commits; never let it publish a stale deadline.
162
+ const committedAddress = await assetClientInterface.getReceiveAddress({
163
+ assetName,
164
+ walletAccount,
165
+ useCache: true,
166
+ })
167
+ if (
168
+ generations.get(accountKey) === generation &&
169
+ committedAddress.toString().toLowerCase() === address
170
+ ) {
171
+ for (const [key, entry] of completed) freshness.set(key, entry)
172
+ }
173
+ }
174
+ const previousCommit = commits.get(accountKey) || Promise.resolve()
175
+ const commit = previousCommit.catch(() => {}).then(publish)
176
+ commits.set(accountKey, commit)
177
+ try {
178
+ await commit
179
+ } finally {
180
+ if (commits.get(accountKey) === commit) commits.delete(accountKey)
181
+ }
62
182
  }
63
183
 
64
184
  return {
185
+ // A restart/resume exposed through monitor.start must rediscover restored
186
+ // accounts. No host screen lifecycle is assumed; update({refresh:true}) and
187
+ // direct getStakingInfo({address}) are explicit uncached refresh paths.
188
+ 'before-start': () => {
189
+ freshness.clear()
190
+ addresses.clear()
191
+ generations.clear()
192
+ },
65
193
  'after-tick': afterTickHook,
66
194
  }
67
195
  }
@@ -0,0 +1,65 @@
1
+ import { isPendingTxInLog } from '@exodus/ethereum-lib'
2
+
3
+ import { EthereumStaking } from '../staking/ethereum/api.js'
4
+ import { MaticStakingApi } from '../staking/matic/api.js'
5
+
6
+ const STAKING_FIELDS = ['delegate', 'undelegate', 'undelegatePending', 'claimUndelegate', 'rewards']
7
+
8
+ // Inspect both logs: Polygon calls without token transfers only live in ETH's
9
+ // log. Any interaction with a staking contract is conservatively relevant.
10
+ export const getStakingActivity = ({ assetName, txLogs }) => {
11
+ const addresses =
12
+ assetName === 'polygon'
13
+ ? [MaticStakingApi.EVERSTAKE_VALIDATOR_CONTRACT_ADDR, MaticStakingApi.STAKING_MANAGER_ADDR]
14
+ : Object.values(EthereumStaking.addresses[assetName] || {})
15
+ const contracts = new Set(addresses.map((address) => address.toLowerCase()))
16
+ const isContract = (address) =>
17
+ typeof address === 'string' && contracts.has(address.toLowerCase())
18
+ const entries = new Map()
19
+ let pending = false
20
+ for (const txs of txLogs) {
21
+ for (const tx of txs) {
22
+ const relevant =
23
+ isContract(tx.to) ||
24
+ (Array.isArray(tx.from) ? tx.from.some(isContract) : isContract(tx.from)) ||
25
+ (tx.coinName === assetName &&
26
+ STAKING_FIELDS.some((field) => tx.data?.[field] !== undefined))
27
+ if (!relevant) continue
28
+ const isPending = isPendingTxInLog(tx)
29
+ pending ||= isPending
30
+ // Do not include increasing confirmation counts or processor-added metadata:
31
+ // neither constitutes a new staking event. Include block identity for reorgs.
32
+ entries.set(`${tx.coinName}:${tx.txId}`, [
33
+ tx.txId,
34
+ isPending,
35
+ tx.confirmations > 0,
36
+ tx.error || null,
37
+ !!tx.dropped,
38
+ tx.data?.replacedBy || null,
39
+ tx.data?.blockHash || tx.blockHash || null,
40
+ tx.data?.blockNumber || tx.blockHeight || null,
41
+ ])
42
+ }
43
+ }
44
+ return {
45
+ pending,
46
+ fingerprint: JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b))),
47
+ }
48
+ }
49
+
50
+ export const stakingStateInterval = (info) => {
51
+ // A pending validator activation/exit is chain state, not a pending local tx.
52
+ // It must never keep a wallet on the one-minute transaction cadence forever.
53
+ const active =
54
+ info.isDelegating ||
55
+ info.isUndelegateInProgress ||
56
+ info.canClaimUndelegatedBalance ||
57
+ [
58
+ 'delegatedBalance',
59
+ 'pendingBalance',
60
+ 'pendingDepositedBalance',
61
+ 'unclaimedUndelegatedBalance',
62
+ 'liquidRewards',
63
+ ].some((key) => info[key]?.isPositive)
64
+ return (active ? 5 : 15) * 60_000
65
+ }
package/src/index.js CHANGED
@@ -42,6 +42,8 @@ export { getHistoricalBalanceFactory } from './get-historical-balance.js'
42
42
  export { createEncodeMultisigContract } from './multisig-address.js'
43
43
 
44
44
  export { FantomStaking, stakingProviderClientFactory } from './staking/index.js'
45
+ export { EthLikeRpcRequestAccumulator } from './multicall3/index.js'
46
+ export { createCachedStakingRead } from './staking/cached-read.js'
45
47
  export { ethereumStakingDeps } from './staking/ethereum/deps.js'
46
48
  export { getEthereumStakingInfo, createEthereumStakingService } from './staking/ethereum/service.js'
47
49
  export { EthereumStaking } from './staking/ethereum/api.js'
@@ -7,6 +7,7 @@ export const stakingApiFactory = ({
7
7
  server, // coin node server
8
8
  stakingConfiguration,
9
9
  stakingDependencies,
10
+ createRpcRequestAccumulator,
10
11
  }) => {
11
12
  assert(assetName, '"assetName" is required')
12
13
  assert(currency, '"currency" is required')
@@ -18,7 +19,14 @@ export const stakingApiFactory = ({
18
19
  const { contracts, minAmount } = stakingConfiguration
19
20
  const { stakingServerFactory, stakingServiceFactory } = stakingDependencies
20
21
 
21
- const stakingServer = stakingServerFactory({ assetName, currency, server, minAmount, contracts })
22
+ const stakingServer = stakingServerFactory({
23
+ assetName,
24
+ currency,
25
+ server,
26
+ minAmount,
27
+ contracts,
28
+ createRpcRequestAccumulator,
29
+ })
22
30
 
23
31
  const stakingService = stakingServiceFactory({
24
32
  assetClientInterface,
@@ -0,0 +1,33 @@
1
+ // Background-only caching. Callers outside the monitor always request fresh data.
2
+ // Keep this state in memory: restored accounts must be discovered again immediately.
3
+ export const createCachedStakingRead = ({
4
+ read,
5
+ interval = 15 * 60_000,
6
+ now = () => performance.now(),
7
+ }) => {
8
+ const entries = new Map()
9
+ return async (key, { useCache = false } = {}) => {
10
+ let entry = entries.get(key)
11
+ if (!entry) {
12
+ entry = { generation: 0 }
13
+ entries.set(key, entry)
14
+ }
15
+ if (useCache) {
16
+ if (entry.pending) return entry.pending
17
+ if (entry.deadline > now()) return entry.value
18
+ }
19
+ const generation = ++entry.generation
20
+ const pending = Promise.resolve().then(() => read(key))
21
+ entry.pending = pending
22
+ try {
23
+ const value = await pending
24
+ if (entry.generation === generation) {
25
+ entry.value = value
26
+ entry.deadline = now() + interval
27
+ }
28
+ return value
29
+ } finally {
30
+ if (entry.generation === generation) entry.pending = undefined
31
+ }
32
+ }
33
+ }
@@ -5,6 +5,8 @@ import { retry } from '@exodus/simple-retry'
5
5
 
6
6
  import { EVM_ERROR_REASONS, withErrorReason } from '../../error-wrapper.js'
7
7
  import { getServerByName } from '../../exodus-eth-server/index.js'
8
+ import { EthLikeRpcRequestAccumulator } from '../../multicall3/index.js'
9
+ import { createCachedStakingRead } from '../cached-read.js'
8
10
 
9
11
  // TODO: Shouldn't this be a function of `ethereumStakingState.minDelegateAmount`?
10
12
  const MIN_AMOUNT = '0.1'
@@ -93,6 +95,45 @@ export class EthereumStaking {
93
95
  })
94
96
  }
95
97
 
98
+ // Do not use Multicall for these reads without verifying the deployed Accounting
99
+ // implementation (currently unverified). JSON-RPC batching preserves msg.sender
100
+ // and isolated eth_call execution, including autocompound simulation semantics.
101
+ async getStakingState(address) {
102
+ const read = () => {
103
+ const requests = new EthLikeRpcRequestAccumulator()
104
+ for (const method of [
105
+ 'autocompoundBalanceOf',
106
+ 'pendingBalanceOf',
107
+ 'pendingDepositedBalanceOf',
108
+ 'withdrawRequest',
109
+ ]) {
110
+ requests.contractStaticCallRequest(this.contractAccounting, method, address)
111
+ }
112
+ return requests.flush(this.server || getServerByName(this.asset.name))
113
+ }
114
+ const [[active], [pending], [deposited], [requested, readyForClaim]] = await withErrorReason({
115
+ promise: retry(read, { delayTimesMs: RETRY_DELAYS })(),
116
+ errorReasonInfo: EVM_ERROR_REASONS.ethCallFailed,
117
+ hint: safeString`stakingEthReadFunctionContract`,
118
+ baseAssetName: this.asset.name,
119
+ })
120
+ const { currency } = this.asset
121
+ return {
122
+ activeStakedBalance: currency.baseUnit(active),
123
+ pendingBalance: currency.baseUnit(pending),
124
+ pendingDepositedBalance: currency.baseUnit(deposited),
125
+ withdrawRequest: {
126
+ requested: currency.baseUnit(requested),
127
+ readyForClaim: currency.baseUnit(readyForClaim),
128
+ canSendClaimRequest: requested !== '0' && requested === readyForClaim,
129
+ },
130
+ }
131
+ }
132
+
133
+ getCachedTotalRewards = createCachedStakingRead({
134
+ read: (address) => this.getTotalRewards(address, { throwOnError: true }),
135
+ })
136
+
96
137
  // === ACCOUNTING ===
97
138
 
98
139
  /** Return total deposited and activated pool balance */
@@ -163,7 +204,7 @@ export class EthereumStaking {
163
204
  return compoundBalance.sub(depositedBalance)
164
205
  }
165
206
 
166
- async getTotalRewards(address) {
207
+ async getTotalRewards(address, { throwOnError = false } = {}) {
167
208
  // use external everstake API to get total rewards
168
209
  const url = new URL(EVERSTAKE_API_URL)
169
210
 
@@ -177,11 +218,21 @@ export class EthereumStaking {
177
218
 
178
219
  try {
179
220
  const response = await fetch(url.toString(), options)
221
+ if (!response.ok) throw new Error('Failed to fetch ETH total rewards')
180
222
  const rewards = await response.json()
181
223
  if (rewards === 'address') return this.asset.currency.ZERO // address not found
224
+ if (
225
+ !(
226
+ (typeof rewards === 'string' && /^\d+$/.test(rewards)) ||
227
+ (Number.isSafeInteger(rewards) && rewards >= 0)
228
+ )
229
+ ) {
230
+ throw new Error('Malformed ETH total rewards')
231
+ }
182
232
  return this.asset.currency.baseUnit(rewards)
183
233
  } catch (error) {
184
234
  console.warn('Error fetching ETH total rewards:', error)
235
+ if (throwOnError) throw error
185
236
  return this.asset.currency.ZERO
186
237
  }
187
238
  }
@@ -14,11 +14,15 @@ export const stakingServiceFactory = ({ assetClientInterface, server, stakingSer
14
14
 
15
15
  return {
16
16
  ...service,
17
- getStakingInfo: ({ address }) =>
17
+ getStakingInfo: ({ address, useCache = false, useQueueCache = useCache, background = false }) =>
18
18
  getEthereumStakingInfo({
19
19
  address,
20
20
  asset: stakingServer.asset,
21
21
  server,
22
+ staking: stakingServer,
23
+ useCache,
24
+ useQueueCache,
25
+ background,
22
26
  }),
23
27
  stake: service.delegate,
24
28
  unstake: service.undelegate,
@@ -2,6 +2,7 @@ import { fetch as exodusFetch } from '@exodus/fetch'
2
2
  import { TraceId } from '@exodus/traceparent'
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
+ import { createCachedStakingRead } from '../cached-read.js'
5
6
  import { UNSTAKE_DEFAULTS } from './api.js'
6
7
 
7
8
  const BASE_URL = 'https://eth-clarity.a.exodus.io/api/v2/ethereum/proxy/everstake/'
@@ -79,6 +80,12 @@ export const simulateEverstakeUnstake = async ({
79
80
  }
80
81
  }
81
82
 
83
+ // The queue is global, not delegator-specific. Share successful background reads
84
+ // across wallets; explicit refreshes bypass both its deadline and in-flight work.
85
+ export const getCachedEverstakeValidatorsQueue = createCachedStakingRead({
86
+ read: () => getEverstakeValidatorsQueue(),
87
+ })
88
+
82
89
  export const getEverstakeValidatorsQueue = async () => {
83
90
  // https://swagger.eth-api-b2c.everstake.one/#/Staking/validatorsQueue
84
91
  const result = await fetch('v1/validators/queue')
@@ -70,7 +70,7 @@ import { createWatchTx as defaultCreateWatch } from '../../watch-tx.js'
70
70
  import { stakingProviderClientFactory } from '../staking-provider-client.js'
71
71
  import { amountToCurrency, DISABLE_BALANCE_CHECKS, resolveFeeData } from '../utils/index.js'
72
72
  import { EthereumStaking } from './api.js'
73
- import { getEverstakeValidatorsQueue } from './everstake.js'
73
+ import { getCachedEverstakeValidatorsQueue } from './everstake.js'
74
74
  import { simulateUndelegateTransactions } from './staking-utils.js'
75
75
 
76
76
  const WETH9_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
@@ -99,7 +99,7 @@ const STAKING_OPERATION_FALLBACK_FEE_ESTIMATES = {
99
99
 
100
100
  const getStakingApi = memoize(
101
101
  (asset) => new EthereumStaking(asset.name, asset.currency, undefined, asset.server),
102
- (asset) => asset.name
102
+ (asset) => asset
103
103
  )
104
104
 
105
105
  export async function getUndelegatePendingData({
@@ -775,25 +775,29 @@ export function createEthereumStakingService({
775
775
  }
776
776
  }
777
777
 
778
- export async function getEthereumStakingInfo({ address, asset, server }) {
779
- const { name: assetName, currency } = asset
778
+ export async function getEthereumStakingInfo({
779
+ address,
780
+ asset,
781
+ server,
782
+ staking = new EthereumStaking(asset.name, asset.currency, undefined, server),
783
+ useCache = false,
784
+ useQueueCache = useCache,
785
+ background = false,
786
+ }) {
787
+ const { currency } = asset
780
788
  const delegator = address.toLowerCase()
781
- const staking = new EthereumStaking(assetName, currency, undefined, server)
782
-
783
789
  const [
784
- activeStakedBalance,
785
- pendingBalance,
786
- pendingDepositedBalance,
787
- withdrawRequest,
790
+ { activeStakedBalance, pendingBalance, pendingDepositedBalance, withdrawRequest },
788
791
  rewardsBalance,
789
792
  everstakeValidatorsQueue,
790
793
  ] = await Promise.all([
791
- staking.autocompoundBalanceOf(delegator),
792
- staking.pendingBalanceOf(delegator),
793
- staking.pendingDepositedBalanceOf(delegator),
794
- staking.withdrawRequest(delegator),
795
- staking.getTotalRewards(delegator),
796
- getEverstakeValidatorsQueue().catch(() => null),
794
+ staking.getStakingState(delegator),
795
+ // Provider availability must not prevent restoring on-chain stake. Omit
796
+ // failed metadata so the monitor preserves the last successful rewards.
797
+ staking
798
+ .getCachedTotalRewards(delegator, { useCache })
799
+ .catch(() => (background ? null : currency.ZERO)),
800
+ getCachedEverstakeValidatorsQueue('ethereum', { useCache: useQueueCache }).catch(() => null),
797
801
  ])
798
802
 
799
803
  const delegatedBalance = activeStakedBalance.add(pendingBalance).add(pendingDepositedBalance)
@@ -813,7 +817,7 @@ export async function getEthereumStakingInfo({ address, asset, server }) {
813
817
  const unclaimedUndelegatedBalance = requested
814
818
 
815
819
  return {
816
- rewardsBalance,
820
+ ...(rewardsBalance !== null && { rewardsBalance }),
817
821
  isDelegating,
818
822
  activeStakedBalance, // balance staked into the validator
819
823
  delegatedBalance, // balance staked into the validator + pending
@@ -823,8 +827,12 @@ export async function getEthereumStakingInfo({ address, asset, server }) {
823
827
  unclaimedUndelegatedBalance,
824
828
  canClaimUndelegatedBalance,
825
829
  isUndelegateInProgress,
826
- validatorActivationTime: everstakeValidatorsQueue?.validatorActivationTime,
827
- validatorExitTime: everstakeValidatorsQueue?.validatorExitTime,
828
- validatorWithdrawTime: everstakeValidatorsQueue?.validatorWithdrawTime,
830
+ // Do not replace previous queue estimates with undefined on a transient failure.
831
+ ...(!background && {
832
+ validatorActivationTime: undefined,
833
+ validatorExitTime: undefined,
834
+ validatorWithdrawTime: undefined,
835
+ }),
836
+ ...everstakeValidatorsQueue,
829
837
  }
830
838
  }
@@ -1,4 +1,5 @@
1
- import lodash from 'lodash'
1
+ import { omit } from '@exodus/basic-utils'
2
+ import lodash from 'lodash' // oxlint-disable-line @exodus/oxlint-plugin-restricted-imports/prefer-basic-utils -- groupBy not in basic-utils
2
3
 
3
4
  // Sorts an array of token transfer events into an object of transfer event arrays,
4
5
  // whose keys are the names of the tokens which are being transferred in the corresponding array.
@@ -12,5 +13,5 @@ export default function getTransfersByTokenName(transfers, tokensByAddress) {
12
13
  transfers,
13
14
  (transfer) => tokensByAddress.get(transfer.address.toLowerCase())?.name || 'UNKNOWN'
14
15
  )
15
- return lodash.omit(transfersByTokenName, 'UNKNOWN')
16
+ return omit(transfersByTokenName, ['UNKNOWN'])
16
17
  }