@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 +33 -0
- package/package.json +2 -2
- package/src/create-asset-utils.js +3 -3
- package/src/create-asset.js +8 -7
- package/src/create-token-factory.js +2 -0
- package/src/hooks/monitor.js +161 -33
- package/src/hooks/staking-freshness.js +65 -0
- package/src/index.js +2 -0
- package/src/staking/api/index.js +9 -1
- package/src/staking/cached-read.js +33 -0
- package/src/staking/ethereum/api.js +52 -1
- package/src/staking/ethereum/deps.js +5 -1
- package/src/staking/ethereum/everstake.js +7 -0
- package/src/staking/ethereum/service.js +28 -20
- package/src/tx-log/monitor-utils/get-transfers-by-token-name.js +3 -2
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.
|
|
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": "
|
|
70
|
+
"gitHead": "53bdb90b8e90413468dc13cf8960ef2aa64dac4b"
|
|
71
71
|
}
|
|
@@ -365,13 +365,13 @@ export const createHistoryMonitorFactory = ({
|
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
if (stakingAssetNames.length > 0) {
|
|
368
|
-
const
|
|
368
|
+
const hooks = createEthereumHooks({
|
|
369
369
|
assetClientInterface,
|
|
370
370
|
assetName,
|
|
371
371
|
stakingAssetNames,
|
|
372
372
|
server,
|
|
373
|
-
})
|
|
374
|
-
monitor.addHook(
|
|
373
|
+
})
|
|
374
|
+
for (const [name, hook] of Object.entries(hooks)) monitor.addHook(name, hook)
|
|
375
375
|
}
|
|
376
376
|
|
|
377
377
|
return monitor
|
package/src/create-asset.js
CHANGED
|
@@ -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/hooks/monitor.js
CHANGED
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
const
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
batch
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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'
|
package/src/staking/api/index.js
CHANGED
|
@@ -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({
|
|
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 {
|
|
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
|
|
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({
|
|
779
|
-
|
|
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.
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
staking
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
|
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
|
|
16
|
+
return omit(transfersByTokenName, ['UNKNOWN'])
|
|
16
17
|
}
|