@exodus/ethereum-api 8.78.2 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/package.json +3 -3
  3. package/src/allowance/index.js +1 -3
  4. package/src/create-asset-utils.js +5 -5
  5. package/src/create-asset.js +8 -7
  6. package/src/create-token-factory.js +2 -0
  7. package/src/ens/index.js +0 -2
  8. package/src/exodus-eth-server/api-coin-nodes.js +7 -2
  9. package/src/exodus-eth-server/api.js +3 -3
  10. package/src/exodus-eth-server/clarity.js +3 -3
  11. package/src/exodus-eth-server/eth-like-server.js +17 -9
  12. package/src/exodus-eth-server/index.js +0 -6
  13. package/src/fee-data/EthLikeFeeData.js +0 -2
  14. package/src/hooks/monitor.js +161 -33
  15. package/src/hooks/staking-freshness.js +65 -0
  16. package/src/index.js +2 -0
  17. package/src/simulation/simulate-message-api.js +1 -1
  18. package/src/simulation/simulate-transactions-api.js +1 -1
  19. package/src/staking/api/index.js +9 -1
  20. package/src/staking/cached-read.js +33 -0
  21. package/src/staking/ethereum/api.js +52 -1
  22. package/src/staking/ethereum/deps.js +5 -1
  23. package/src/staking/ethereum/everstake.js +7 -0
  24. package/src/staking/ethereum/service.js +31 -23
  25. package/src/staking/utils/index.js +0 -2
  26. package/src/tx-log/clarity-monitor.js +59 -23
  27. package/src/tx-log/ethereum-monitor.js +54 -23
  28. package/src/tx-log/get-optimistic-txlog-effects.js +1 -1
  29. package/src/tx-log/monitor-utils/check-pending-transactions.js +12 -3
  30. package/src/tx-log/monitor-utils/get-derive-transactions-to-check.js +7 -3
  31. package/src/tx-log/monitor-utils/get-transfers-by-token-name.js +3 -2
  32. package/src/tx-log/monitor-utils/index.js +1 -0
  33. package/src/tx-log/monitor-utils/is-unverifiable-pending-tx-expired.js +19 -0
  34. package/src/tx-log/monitor-utils/verify-pending-tx-status-rpc.js +34 -11
  35. package/src/tx-log-staking-processor/utils.js +1 -1
  36. package/src/tx-send/broadcast-error-handler.js +2 -2
  37. package/src/tx-send/tx-send.js +3 -3
package/CHANGELOG.md CHANGED
@@ -3,6 +3,81 @@
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
+
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)
40
+
41
+
42
+ ### Features
43
+
44
+
45
+ * feat: source eip7702 delegation whitelist from remote config (#8356)
46
+
47
+
48
+ ### Bug Fixes
49
+
50
+
51
+ * fix: disable @exodus/hydra/no-param-reassign-prop-only warnings (#8531)
52
+
53
+ * fix(ethereum-api): detect legacy delegate transactions (#8357)
54
+
55
+ * fix(ethereum-api): preserve live pending txs across refresh to prevent nonce reuse (#8430)
56
+
57
+ * fix(ethereum-api): preserve precision for wei values in tx simulation (#7920)
58
+
59
+ * fix(ethereum-api): reference defined ethereum asset in simulate-tx tests (#8525)
60
+
61
+ * fix(ethereum): prevent silently dropped zero-tip private bundles (#8493)
62
+
63
+ * fix(lint): add explicit process imports and enforce no-implicit-process (#8530)
64
+
65
+ * fix(lint): convert console.log/info to console.debug and enforce no-console (#8529)
66
+
67
+ * fix(lint): fix import/named warnings and upgrade to error (#8496)
68
+
69
+ * fix(lint): fix JSDoc warnings across multiple packages (#8519)
70
+
71
+ * fix(lint): fix no-throw-literal warnings and upgrade to error (#8498)
72
+
73
+ * fix(lint): fix oxlint warnings and convert rules to error (#8489)
74
+
75
+ * fix(simulation): route recipient changes to dedicated field, guard asset-less entries (#8370)
76
+
77
+ * fix: upgrade low-complexity lint rules to error (#8510)
78
+
79
+
80
+
6
81
  ## [8.78.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.78.2) (2026-08-14)
7
82
 
8
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.78.2",
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",
@@ -17,7 +17,7 @@
17
17
  "provenance": false
18
18
  },
19
19
  "scripts": {
20
- "test": "run -T exodus-test --jest"
20
+ "test": "exodus-test --jest"
21
21
  },
22
22
  "dependencies": {
23
23
  "@exodus/asset": "^2.0.4",
@@ -67,5 +67,5 @@
67
67
  "type": "git",
68
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
69
  },
70
- "gitHead": "07b29ff2dc71fb1358ad6b1b6748c3f9fe66c10e"
70
+ "gitHead": "53bdb90b8e90413468dc13cf8960ef2aa64dac4b"
71
71
  }
@@ -1,4 +1,3 @@
1
- // eslint-disable-next-line @exodus/import/no-deprecated
2
1
  import { isNumberUnit } from '@exodus/currency'
3
2
  import { buffer2currency, createUnsignedTxFactory } from '@exodus/ethereum-lib'
4
3
  import { bufferToInt } from '@exodus/ethereumjs/util'
@@ -46,7 +45,6 @@ export const buildApproveTx = async ({
46
45
  }) => {
47
46
  assert(asset, 'expected asset')
48
47
  assert(feeData, 'expected feeData')
49
- // eslint-disable-next-line @exodus/import/no-deprecated
50
48
  assert(isNumberUnit(approveAmount), 'expected approveAmount')
51
49
  assert(typeof spenderAddress === 'string', 'expected spenderAddress')
52
50
  assert(walletAccount, 'expected walletAccount')
@@ -231,7 +229,7 @@ export const createApproveSpendingTokens =
231
229
 
232
230
  txIds.push(revokeTokenApprovalTxId)
233
231
  // Hardcoded min gas limit. Otherwise `fetchGasLimit` will fail since `approve(_spender, 0)` is not yet confirmed
234
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
232
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
235
233
  data.gasLimit = Math.max(data.gasLimit || 0, APPROVAL_GAS_LIMIT)
236
234
  }
237
235
 
@@ -62,7 +62,7 @@ export const resolveMonitorSettings = (
62
62
  }
63
63
 
64
64
  const fallbackWithWarning = (msg) => {
65
- console.log([msg, `Falling back to ${defaultMonitorType}<${defaultServerUrl}>.`].join(' '))
65
+ console.debug([msg, `Falling back to ${defaultMonitorType}<${defaultServerUrl}>.`].join(' '))
66
66
  return defaultResolution
67
67
  }
68
68
 
@@ -317,7 +317,7 @@ export const createHistoryMonitorFactory = ({
317
317
  let monitor
318
318
  switch (monitorType) {
319
319
  case 'clarity-v3':
320
- console.log('clarity-v3 is no longer supported, falling back to clarity-v2')
320
+ console.debug('clarity-v3 is no longer supported, falling back to clarity-v2')
321
321
  // eslint-disable-next-line no-fallthrough
322
322
  case 'clarity':
323
323
  case 'clarity-v2':
@@ -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
 
package/src/ens/index.js CHANGED
@@ -1,4 +1,3 @@
1
- // eslint-disable-next-line @exodus/import/no-deprecated
2
1
  import { hashSync } from '@exodus/crypto/hash'
3
2
  import { ABI } from '@exodus/ethereum-lib'
4
3
  import SolidityContract from '@exodus/solidity-contract'
@@ -25,7 +24,6 @@ function namehash(inputName) {
25
24
  const name = normalize(inputName)
26
25
  if (name) {
27
26
  for (const label of name.split('.').reverse()) {
28
- // eslint-disable-next-line @exodus/import/no-deprecated
29
27
  node = hashSync('keccak256', [node, hashSync('keccak256', label, 'uint8')], 'buffer')
30
28
  }
31
29
  }
@@ -29,10 +29,15 @@ export default class ApiCoinNodesServer extends EthLikeServer {
29
29
  return fetchJsonRetry(this.uri, options)
30
30
  }
31
31
 
32
- async sendBatchRequest(batch) {
32
+ async sendBatchRequest(batch, { tolerant } = {}) {
33
33
  if (isEmpty(batch)) return batch
34
34
  const responses = await this.request(batch)
35
- return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad rpc batch response' })
35
+ return this.parseBatchResponse({
36
+ batch,
37
+ responses,
38
+ errorMessage: 'Bad rpc batch response',
39
+ tolerant,
40
+ })
36
41
  }
37
42
 
38
43
  async sendRequest(request) {
@@ -263,11 +263,11 @@ export function create(defaultURL, ensAssetName) {
263
263
  },
264
264
 
265
265
  async proxyToCoinNode(requestData) {
266
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
266
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
267
267
  if (!requestData.jsonrpc) requestData.jsonrpc = '2.0'
268
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
268
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
269
269
  if (!requestData.id) requestData.id = randomUUID()
270
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
270
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
271
271
  if (!requestData.params) requestData.params = []
272
272
 
273
273
  return request('proxy', requestData, { version: 'v2', method: 'post' })
@@ -198,7 +198,7 @@ export default class ClarityServer extends EthLikeServer {
198
198
  clearTimeout(timeout)
199
199
  const rawTraceparent = response?.traceparent || response?.[0]?.traceparent
200
200
  if (rawTraceparent) {
201
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
201
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
202
202
  response.traceparent = TraceId.fromRawTraceparent(rawTraceparent)
203
203
  }
204
204
 
@@ -208,9 +208,9 @@ export default class ClarityServer extends EthLikeServer {
208
208
  }
209
209
 
210
210
  // Transport: WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
211
- async sendBatchRequest(batch) {
211
+ async sendBatchRequest(batch, { tolerant } = {}) {
212
212
  const responses = await this.sendRpcRequest(batch)
213
- return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad Response' })
213
+ return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad Response', tolerant })
214
214
  }
215
215
 
216
216
  // Transport: Uses sendRpcRequest - WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
@@ -165,10 +165,11 @@ export class EthLikeServer extends EthLikeServerBase {
165
165
  * @param {Array<{ id: number|string }>} args.batch - Requests sent, in the order results are expected.
166
166
  * @param {unknown} args.responses - Raw transport payload; expected `[{ id, result }]` but treated as untrusted (gateways may return `{ message }` on error).
167
167
  * @param {string} args.errorMessage - Thrown-error prefix per transport (e.g. `'Bad rpc batch response'`, `'Bad Response'`).
168
- * @returns {unknown[]} Each request's `result`, ordered to match `batch`.
169
- * @throws {Error} When `responses` isn't an array, length mismatches `batch`, or any entry lacks a numeric `id` / defined `result`.
168
+ * @param {boolean} [args.tolerant] - When true, a request without a valid entry (JSON-RPC error entry, non-numeric `id`, duplicated or out-of-batch `id`, missing from a short response) maps to `undefined` instead of failing the whole batch.
169
+ * @returns {unknown[]} Each request's `result`, ordered to match `batch`. Entries with a duplicated or out-of-batch `id` are discarded in both modes, so an affected request maps to `undefined` rather than another request's result.
170
+ * @throws {Error} When `responses` isn't an array; in strict mode also when length mismatches `batch` or any entry lacks a numeric `id` / defined `result`.
170
171
  */
171
- parseBatchResponse({ batch, responses, errorMessage }) {
172
+ parseBatchResponse({ batch, responses, errorMessage, tolerant = false }) {
172
173
  // Gateways can respond with a JSON object (e.g. `{ message }`) instead of a batch array.
173
174
  if (!Array.isArray(responses)) {
174
175
  // Untrusted server text: keep only a short string preview, developer-facing (no `hint`).
@@ -179,16 +180,23 @@ export class EthLikeServer extends EthLikeServerBase {
179
180
  throw new Error(`${errorMessage}${preview}`)
180
181
  }
181
182
 
182
- const isValid = responses.every(
183
- (response) => !isNaN(response?.id) && response?.result !== undefined
184
- )
185
- if (responses.length !== batch.length || !isValid) {
183
+ const isValidEntry = (response) => !isNaN(response?.id) && response?.result !== undefined
184
+
185
+ if (!tolerant && (responses.length !== batch.length || !responses.every(isValidEntry))) {
186
186
  throw new Error(errorMessage)
187
187
  }
188
188
 
189
- // Safe against prototype keys: every `id` is validated numeric by the `isValid` check above.
189
+ // Safe against prototype keys: only entries with a validated numeric `id` are kept.
190
190
  const keyed = Object.fromEntries(
191
- responses.map((response) => [`${response.id}`, response.result])
191
+ responses
192
+ .filter(isValidEntry)
193
+ .filter(({ id }, _i, all) => {
194
+ const key = `${id}`
195
+ const inBatch = batch.some((request) => `${request.id}` === key)
196
+ const unique = all.filter(({ id: other }) => `${other}` === key).length === 1
197
+ return inBatch && unique
198
+ })
199
+ .map((response) => [`${response.id}`, response.result])
192
200
  )
193
201
  return batch.map((request) => keyed[`${request.id}`])
194
202
  }
@@ -1,9 +1,6 @@
1
1
  import {
2
- // eslint-disable-next-line @exodus/import/no-deprecated
3
2
  DEFAULT_SERVER_URLS,
4
- // eslint-disable-next-line @exodus/import/no-deprecated
5
3
  ETHEREUM_LIKE_ASSETS,
6
- // eslint-disable-next-line @exodus/import/no-deprecated
7
4
  ETHEREUM_LIKE_MONITOR_TYPES,
8
5
  } from '@exodus/ethereum-lib'
9
6
  import assert from 'minimalistic-assert'
@@ -80,11 +77,8 @@ export const createWsGateway = ({ uri } = Object.create(null)) => {
80
77
 
81
78
  // @Deprecated
82
79
  const serverMap = Object.fromEntries(
83
- // eslint-disable-next-line @exodus/import/no-deprecated
84
80
  ETHEREUM_LIKE_ASSETS.map((assetName) => {
85
- // eslint-disable-next-line @exodus/import/no-deprecated
86
81
  const monitorType = ETHEREUM_LIKE_MONITOR_TYPES[assetName]
87
- // eslint-disable-next-line @exodus/import/no-deprecated
88
82
  const serverUrl = DEFAULT_SERVER_URLS[assetName]
89
83
  return [assetName, createEvmServer({ assetName, serverUrl, monitorType })]
90
84
  })
@@ -3,7 +3,6 @@ import {
3
3
  mapCurrency as defaultMapCurrency,
4
4
  modelToJSON,
5
5
  } from '@exodus/asset-lib/src/utils/index.js'
6
- // eslint-disable-next-line @exodus/import/no-deprecated
7
6
  import { isNumberUnit } from '@exodus/currency'
8
7
  import lodash from 'lodash'
9
8
 
@@ -13,7 +12,6 @@ const PRE_EIP1559_UNIT_KEYS_GUARD = ['gasPrice']
13
12
  const EIP1559_UNIT_KEYS_GUARD = ['gasPrice', 'baseFeePerGas']
14
13
 
15
14
  export const bound = ({ val, multiplier, max, min }) => {
16
- // eslint-disable-next-line @exodus/import/no-deprecated
17
15
  if (!isNumberUnit(val)) return val
18
16
 
19
17
  if (multiplier) {
@@ -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
  }