@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.
- package/CHANGELOG.md +75 -0
- package/package.json +3 -3
- package/src/allowance/index.js +1 -3
- package/src/create-asset-utils.js +5 -5
- package/src/create-asset.js +8 -7
- package/src/create-token-factory.js +2 -0
- package/src/ens/index.js +0 -2
- package/src/exodus-eth-server/api-coin-nodes.js +7 -2
- package/src/exodus-eth-server/api.js +3 -3
- package/src/exodus-eth-server/clarity.js +3 -3
- package/src/exodus-eth-server/eth-like-server.js +17 -9
- package/src/exodus-eth-server/index.js +0 -6
- package/src/fee-data/EthLikeFeeData.js +0 -2
- package/src/hooks/monitor.js +161 -33
- package/src/hooks/staking-freshness.js +65 -0
- package/src/index.js +2 -0
- package/src/simulation/simulate-message-api.js +1 -1
- package/src/simulation/simulate-transactions-api.js +1 -1
- 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 +31 -23
- package/src/staking/utils/index.js +0 -2
- package/src/tx-log/clarity-monitor.js +59 -23
- package/src/tx-log/ethereum-monitor.js +54 -23
- package/src/tx-log/get-optimistic-txlog-effects.js +1 -1
- package/src/tx-log/monitor-utils/check-pending-transactions.js +12 -3
- package/src/tx-log/monitor-utils/get-derive-transactions-to-check.js +7 -3
- package/src/tx-log/monitor-utils/get-transfers-by-token-name.js +3 -2
- package/src/tx-log/monitor-utils/index.js +1 -0
- package/src/tx-log/monitor-utils/is-unverifiable-pending-tx-expired.js +19 -0
- package/src/tx-log/monitor-utils/verify-pending-tx-status-rpc.js +34 -11
- package/src/tx-log-staking-processor/utils.js +1 -1
- package/src/tx-send/broadcast-error-handler.js +2 -2
- package/src/tx-send/tx-send.js +3 -3
|
@@ -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'
|
|
@@ -30,7 +30,7 @@ const convertToAPIPayload = ({ address, message, url }) => {
|
|
|
30
30
|
|
|
31
31
|
export const handleActionResponse = ({ simulationResult, response }) => {
|
|
32
32
|
if (['BLOCK', 'WARN'].includes(response.action)) {
|
|
33
|
-
// eslint-disable-next-line @exodus/
|
|
33
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
34
34
|
simulationResult.action = response.action
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -194,7 +194,7 @@ export const simulateTransactionsApi = async ({
|
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
// eslint-disable-next-line @exodus/
|
|
197
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
198
198
|
simulationResult.metadata.simulatedLocally = false
|
|
199
199
|
|
|
200
200
|
const { expectedStateChanges = {}, error } = response.simulationResults.aggregated
|
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({
|
|
@@ -178,7 +178,7 @@ export function createEthereumStakingService({
|
|
|
178
178
|
stakingProvider = stakingProviderClientFactory(),
|
|
179
179
|
}) {
|
|
180
180
|
if (deprectedArg && !assetName) {
|
|
181
|
-
console.
|
|
181
|
+
console.debug('using deprecated ARG `asset` instead of `assetName`')
|
|
182
182
|
assetName = deprectedArg.name
|
|
183
183
|
}
|
|
184
184
|
|
|
@@ -212,7 +212,7 @@ export function createEthereumStakingService({
|
|
|
212
212
|
|
|
213
213
|
const { to, data } = await staking.stake({ amount })
|
|
214
214
|
|
|
215
|
-
console.
|
|
215
|
+
console.debug(
|
|
216
216
|
`delegator address ${delegatorAddress} staking ${amount.toDefaultString({
|
|
217
217
|
unit: true,
|
|
218
218
|
})}`
|
|
@@ -376,7 +376,7 @@ export function createEthereumStakingService({
|
|
|
376
376
|
},
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
console.
|
|
379
|
+
console.debug(
|
|
380
380
|
`delegator address ${delegatorAddress} unstaking ${requestedAmount.toDefaultString({
|
|
381
381
|
unit: true,
|
|
382
382
|
})} - pending amount: ${pendingAmount.toDefaultString({ unit: true })}`
|
|
@@ -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,10 +1,8 @@
|
|
|
1
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
2
1
|
import { isNumberUnit } from '@exodus/currency'
|
|
3
2
|
|
|
4
3
|
export const DISABLE_BALANCE_CHECKS = '0x0'
|
|
5
4
|
|
|
6
5
|
export function amountToCurrency({ asset, amount }) {
|
|
7
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
8
6
|
return isNumberUnit(amount) ? amount : asset.currency.parse(amount)
|
|
9
7
|
}
|
|
10
8
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getCurrentEIP7702Delegation,
|
|
20
20
|
getDeriveDataNeededForTick,
|
|
21
21
|
getDeriveTransactionsToCheck,
|
|
22
|
+
isUnverifiablePendingTxExpired,
|
|
22
23
|
verifyRpcPendingTxStatusBatch,
|
|
23
24
|
} from './monitor-utils/index.js'
|
|
24
25
|
|
|
@@ -90,19 +91,23 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
90
91
|
|
|
91
92
|
// eslint-disable-next-line no-undef
|
|
92
93
|
async checkPendingTransactions(params) {
|
|
93
|
-
const {
|
|
94
|
-
|
|
94
|
+
const {
|
|
95
|
+
pendingTransactionsToCheck,
|
|
96
|
+
pendingTransactionsGroupedByAddressAndNonce,
|
|
97
|
+
recentPendingTransactions,
|
|
98
|
+
} = checkPendingTransactions(params)
|
|
95
99
|
const txsToRemove = []
|
|
96
100
|
const { walletAccount } = params
|
|
97
101
|
|
|
98
|
-
const updateTx = (tx, asset, { error, remove }) => {
|
|
102
|
+
const updateTx = (tx, asset, { error, remove, preserve, isTxConfirmed }) => {
|
|
99
103
|
if (remove) {
|
|
100
104
|
txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
|
|
105
|
+
} else if (preserve) {
|
|
106
|
+
params.logItemsByAsset[asset].push(tx)
|
|
101
107
|
} else {
|
|
102
108
|
params.logItemsByAsset[asset].push({
|
|
103
109
|
...tx,
|
|
104
|
-
dropped: true,
|
|
105
|
-
error,
|
|
110
|
+
...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error }),
|
|
106
111
|
})
|
|
107
112
|
}
|
|
108
113
|
|
|
@@ -115,11 +120,15 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
115
120
|
assetSource: { asset: assetName, walletAccount },
|
|
116
121
|
})
|
|
117
122
|
} else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
// Optional: the stored tx may name a token since removed from the registry
|
|
124
|
+
params.logItemsByAsset[assetName]?.push(
|
|
125
|
+
preserve
|
|
126
|
+
? tokenTxSet.get(tx.txId)
|
|
127
|
+
: {
|
|
128
|
+
...tokenTxSet.get(tx.txId),
|
|
129
|
+
...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error }),
|
|
130
|
+
}
|
|
131
|
+
)
|
|
123
132
|
}
|
|
124
133
|
})
|
|
125
134
|
return Promise.all(promises)
|
|
@@ -131,28 +140,55 @@ export class ClarityMonitor extends BaseMonitor {
|
|
|
131
140
|
if (replaced) {
|
|
132
141
|
await updateTx(tx, assetName, { remove: true })
|
|
133
142
|
delete pendingTransactionsToCheck[tx.txId]
|
|
143
|
+
delete recentPendingTransactions[tx.txId]
|
|
134
144
|
}
|
|
135
145
|
}
|
|
136
146
|
|
|
137
|
-
// Batch verify all pending txs with a single RPC call
|
|
147
|
+
// Batch verify all pending txs with a single RPC call
|
|
138
148
|
const txIds = Object.keys(pendingTransactionsToCheck)
|
|
139
|
-
const statuses =
|
|
140
|
-
|
|
141
|
-
:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
txIds,
|
|
145
|
-
})
|
|
149
|
+
const statuses = await verifyRpcPendingTxStatusBatch({
|
|
150
|
+
server: this.server,
|
|
151
|
+
logger: this.logger,
|
|
152
|
+
txIds,
|
|
153
|
+
})
|
|
146
154
|
|
|
147
155
|
for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
|
|
156
|
+
const txStatus = statuses[tx.txId]
|
|
148
157
|
if (params.refresh) {
|
|
149
|
-
await updateTx(tx, assetName, { remove: true })
|
|
150
|
-
} else {
|
|
151
|
-
const txStatus = statuses[tx.txId]
|
|
152
158
|
if (txStatus?.status === 'dropped') {
|
|
153
|
-
await updateTx(tx, assetName, {
|
|
159
|
+
await updateTx(tx, assetName, { remove: true })
|
|
160
|
+
} else if (txStatus?.status === 'confirmed') {
|
|
161
|
+
// Confirmed on-chain but missing from the indexer response: mark it
|
|
162
|
+
// confirmed so it stops being a pending entry that would otherwise be
|
|
163
|
+
// preserved (exempt from the age ceiling) on every refresh forever.
|
|
164
|
+
await updateTx(tx, assetName, { isTxConfirmed: true })
|
|
165
|
+
} else if (
|
|
166
|
+
// No txStatus at all means the whole verification batch failed - evidence
|
|
167
|
+
// about nothing, so preserve unconditionally; the age ceiling only applies
|
|
168
|
+
// to a per-entry 'unknown' verdict (evidence about that one hash).
|
|
169
|
+
!txStatus ||
|
|
170
|
+
txStatus.status === 'pending' ||
|
|
171
|
+
!isUnverifiablePendingTxExpired(tx)
|
|
172
|
+
) {
|
|
173
|
+
// Still live on-chain (or not verifiable yet) but missing from the indexer
|
|
174
|
+
// response: re-add it so the refresh overwrite cannot wipe it and break
|
|
175
|
+
// tx-log-based nonce resolution.
|
|
176
|
+
await updateTx(tx, assetName, { preserve: true })
|
|
177
|
+
} else {
|
|
178
|
+
// Unverifiable past the age ceiling: purge so a hash the node keeps
|
|
179
|
+
// erroring on cannot pin nonce derivation forever.
|
|
180
|
+
await updateTx(tx, assetName, { remove: true })
|
|
154
181
|
}
|
|
155
|
-
|
|
182
|
+
} else if (txStatus?.status === 'dropped') {
|
|
183
|
+
await updateTx(tx, assetName, { error: 'Dropped' })
|
|
184
|
+
}
|
|
185
|
+
// non-refresh: 'confirmed', 'pending' or unknown - tx is fine, wait for Clarity to confirm
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (params.refresh) {
|
|
189
|
+
// Too recent for a reliable on-chain verdict - always survive the refresh overwrite
|
|
190
|
+
for (const { tx, assetName } of Object.values(recentPendingTransactions)) {
|
|
191
|
+
await updateTx(tx, assetName, { preserve: true })
|
|
156
192
|
}
|
|
157
193
|
}
|
|
158
194
|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getDeriveTransactionsToCheck,
|
|
13
13
|
getHistoryFromServer,
|
|
14
14
|
getLogItemsFromServerTx,
|
|
15
|
+
isUnverifiablePendingTxExpired,
|
|
15
16
|
verifyRpcPendingTxStatusBatch,
|
|
16
17
|
} from './monitor-utils/index.js'
|
|
17
18
|
import {
|
|
@@ -74,16 +75,21 @@ export class EthereumMonitor extends BaseMonitor {
|
|
|
74
75
|
|
|
75
76
|
// eslint-disable-next-line no-undef
|
|
76
77
|
async checkPendingTransactions(params) {
|
|
77
|
-
const {
|
|
78
|
-
|
|
78
|
+
const {
|
|
79
|
+
pendingTransactionsToCheck,
|
|
80
|
+
pendingTransactionsGroupedByAddressAndNonce,
|
|
81
|
+
recentPendingTransactions,
|
|
82
|
+
} = checkPendingTransactions(params)
|
|
79
83
|
const txsToRemove = []
|
|
80
84
|
const {
|
|
81
85
|
assetSource: { walletAccount },
|
|
82
86
|
} = params
|
|
83
87
|
|
|
84
|
-
const updateTx = (tx, asset, { isTxConfirmed, remove }) => {
|
|
88
|
+
const updateTx = (tx, asset, { isTxConfirmed, remove, preserve }) => {
|
|
85
89
|
if (remove) {
|
|
86
90
|
txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
|
|
91
|
+
} else if (preserve) {
|
|
92
|
+
params.logItemsByAsset[asset].push(tx)
|
|
87
93
|
} else {
|
|
88
94
|
params.logItemsByAsset[asset].push({
|
|
89
95
|
...tx,
|
|
@@ -100,10 +106,15 @@ export class EthereumMonitor extends BaseMonitor {
|
|
|
100
106
|
assetSource: { asset: assetName, walletAccount },
|
|
101
107
|
})
|
|
102
108
|
} else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
// Optional: the stored tx may name a token since removed from the registry
|
|
110
|
+
params.logItemsByAsset[assetName]?.push(
|
|
111
|
+
preserve
|
|
112
|
+
? tokenTxSet.get(tx.txId)
|
|
113
|
+
: {
|
|
114
|
+
...tokenTxSet.get(tx.txId),
|
|
115
|
+
...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error: 'Dropped' }),
|
|
116
|
+
}
|
|
117
|
+
)
|
|
107
118
|
}
|
|
108
119
|
})
|
|
109
120
|
|
|
@@ -116,30 +127,50 @@ export class EthereumMonitor extends BaseMonitor {
|
|
|
116
127
|
if (replaced) {
|
|
117
128
|
await updateTx(tx, assetName, { remove: true })
|
|
118
129
|
delete pendingTransactionsToCheck[tx.txId]
|
|
130
|
+
delete recentPendingTransactions[tx.txId]
|
|
119
131
|
}
|
|
120
132
|
}
|
|
121
133
|
|
|
122
|
-
// Batch verify all pending txs with a single RPC call
|
|
134
|
+
// Batch verify all pending txs with a single RPC call
|
|
123
135
|
const txIds = Object.keys(pendingTransactionsToCheck)
|
|
124
|
-
const statuses =
|
|
125
|
-
|
|
126
|
-
:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
txIds,
|
|
130
|
-
})
|
|
136
|
+
const statuses = await verifyRpcPendingTxStatusBatch({
|
|
137
|
+
server: this.server,
|
|
138
|
+
logger: this.logger,
|
|
139
|
+
txIds,
|
|
140
|
+
})
|
|
131
141
|
|
|
132
142
|
for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
await updateTx(tx, assetName, {
|
|
139
|
-
} else
|
|
143
|
+
const txStatus = statuses[tx.txId]
|
|
144
|
+
if (txStatus?.status === 'confirmed') {
|
|
145
|
+
await updateTx(tx, assetName, { isTxConfirmed: true })
|
|
146
|
+
} else if (txStatus?.status === 'dropped') {
|
|
147
|
+
if (params.refresh) {
|
|
148
|
+
await updateTx(tx, assetName, { remove: true })
|
|
149
|
+
} else {
|
|
140
150
|
await updateTx(tx, assetName, { isTxConfirmed: false })
|
|
141
151
|
}
|
|
142
|
-
|
|
152
|
+
} else if (params.refresh) {
|
|
153
|
+
// No txStatus at all means the whole verification batch failed - evidence
|
|
154
|
+
// about nothing, so preserve unconditionally; the age ceiling only applies
|
|
155
|
+
// to a per-entry 'unknown' verdict (evidence about that one hash).
|
|
156
|
+
if (!txStatus || txStatus.status === 'pending' || !isUnverifiablePendingTxExpired(tx)) {
|
|
157
|
+
// Still in the mempool (or not verifiable yet) but missing from the history
|
|
158
|
+
// server response: re-add it so the refresh overwrite cannot wipe it and
|
|
159
|
+
// break tx-log-based nonce resolution.
|
|
160
|
+
await updateTx(tx, assetName, { preserve: true })
|
|
161
|
+
} else {
|
|
162
|
+
// Unverifiable past the age ceiling: purge so a hash the node keeps
|
|
163
|
+
// erroring on cannot pin nonce derivation forever.
|
|
164
|
+
await updateTx(tx, assetName, { remove: true })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// non-refresh: 'pending' or unknown - tx still in mempool, do nothing
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (params.refresh) {
|
|
171
|
+
// Too recent for a reliable on-chain verdict - always survive the refresh overwrite
|
|
172
|
+
for (const { tx, assetName } of Object.values(recentPendingTransactions)) {
|
|
173
|
+
await updateTx(tx, assetName, { preserve: true })
|
|
143
174
|
}
|
|
144
175
|
}
|
|
145
176
|
|
|
@@ -82,7 +82,7 @@ export const getOptimisticTxLogEffects = async ({
|
|
|
82
82
|
maybeTxToReplace && maybeTxToReplace.feeAmount.lt(feeAmount) ? maybeTxToReplace.txId : undefined
|
|
83
83
|
|
|
84
84
|
if (!replacedTxId && maybeTxToReplace) {
|
|
85
|
-
console.
|
|
85
|
+
console.debug('Attempting to replace a transaction using an insufficient fee!')
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// Contains effects for smart-contract initiated token movements.
|