@exodus/ethereum-api 9.0.0-alpha.3 → 9.0.0
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 +2121 -0
- package/LICENSE +7 -0
- package/README.md +124 -0
- package/package.json +34 -33
- package/src/address-has-history.js +6 -2
- package/src/allowance/constants.js +4 -5
- package/src/allowance/index.js +159 -40
- package/src/check-tx/create-assess-transaction.js +23 -0
- package/src/check-tx/create-check-tx.js +104 -0
- package/src/check-tx/index.js +1 -0
- package/src/create-asset-plugin-factory.js +115 -0
- package/src/create-asset-utils.js +520 -0
- package/src/create-asset.js +272 -97
- package/src/create-token-factory.js +47 -17
- package/src/custom-fees.js +57 -0
- package/src/eip7702-whitelist.js +62 -0
- package/src/ens/index.js +12 -17
- package/src/error-wrapper.js +383 -0
- package/src/eth-like-util.js +172 -33
- package/src/exodus-eth-server/api-coin-nodes.js +27 -196
- package/src/exodus-eth-server/api.js +23 -14
- package/src/exodus-eth-server/clarity-v2.js +255 -0
- package/src/exodus-eth-server/clarity.js +67 -265
- package/src/exodus-eth-server/errors.js +40 -0
- package/src/exodus-eth-server/eth-like-server.js +385 -0
- package/src/exodus-eth-server/fetch-json.js +48 -0
- package/src/exodus-eth-server/index.js +77 -0
- package/src/exodus-eth-server/utils.js +31 -0
- package/src/exodus-eth-server/ws-gateway.js +306 -0
- package/src/exodus-eth-server/ws.js +1 -2
- package/src/fee-data/EthLikeFeeData.js +81 -0
- package/src/fee-data/index.js +54 -0
- package/src/fee-utils.js +229 -0
- package/src/gas-estimation.js +183 -66
- package/src/get-balance-for-address.js +1 -1
- package/src/get-balances.js +285 -27
- package/src/get-fee.js +92 -56
- package/src/get-historical-balance.js +58 -0
- package/src/hooks/index.js +1 -1
- package/src/hooks/monitor.js +182 -43
- package/src/hooks/staking-freshness.js +58 -0
- package/src/index.js +118 -12
- package/src/move-funds.js +153 -0
- package/src/multicall3/index.js +212 -0
- package/src/multisig-address.js +146 -0
- package/src/nft-utils.js +60 -0
- package/src/number-utils.js +34 -2
- package/src/optimism-gas/index.js +13 -5
- package/src/send-validations.js +58 -0
- package/src/server-based-fee-monitor.js +12 -19
- package/src/simulate-tx/fetch-tx-preview.js +0 -2
- package/src/simulate-tx/index.js +2 -2
- package/src/simulate-tx/simulate-eth-tx.js +24 -11
- package/src/simulation/common.js +34 -0
- package/src/simulation/create-simulate-message.js +49 -0
- package/src/simulation/create-simulate-transactions.js +108 -0
- package/src/simulation/estimate-fee.js +14 -0
- package/src/simulation/estimate-simple-transfer.js +15 -0
- package/src/simulation/get-message-type.js +18 -0
- package/src/simulation/simulate-message-api.js +68 -0
- package/src/simulation/simulate-transactions-api.js +266 -0
- package/src/simulation/simulate-transactions.js +16 -0
- package/src/simulation/transactions.js +52 -0
- package/src/simulation/try-estimating-changes-locally.js +26 -0
- package/src/staking/api/index.js +43 -0
- package/src/staking/cached-read.js +33 -0
- package/src/staking/ethereum/api.js +166 -34
- package/src/staking/ethereum/deps.js +37 -0
- package/src/staking/ethereum/everstake.js +113 -0
- package/src/staking/ethereum/index.js +4 -3
- package/src/staking/ethereum/service.js +682 -181
- package/src/staking/ethereum/staking-utils.js +275 -4
- package/src/staking/fantom-staking.js +9 -9
- package/src/staking/index.js +4 -4
- package/src/staking/matic/addresses.js +2 -0
- package/src/staking/matic/api.js +69 -49
- package/src/staking/matic/index.js +3 -3
- package/src/staking/matic/matic-staking-utils.js +243 -5
- package/src/staking/matic/matic-staking.md +53 -0
- package/src/staking/staking-provider-client.js +1 -1
- package/src/staking/utils/index.js +39 -0
- package/src/tx-create.js +666 -0
- package/src/tx-log/clarity-monitor.js +273 -103
- package/src/tx-log/clarity-truncated-history-monitor.js +109 -0
- package/src/tx-log/clarity-truncated-history-monitor.md +219 -0
- package/src/tx-log/clarity-utils/absolute.js +96 -0
- package/src/tx-log/clarity-utils/get-log-items-from-server-tx.js +94 -31
- package/src/tx-log/clarity-utils/get-names-of-tokens-transferred-by-server-tx.js +3 -2
- package/src/tx-log/clarity-utils/index.js +9 -3
- package/src/tx-log/clarity-utils/is-spam-pending-tx.js +13 -0
- package/src/tx-log/clarity-utils/normalize-transactions-response.js +65 -0
- package/src/tx-log/ethereum-monitor.js +93 -47
- package/src/tx-log/ethereum-no-history-monitor.js +52 -41
- package/src/tx-log/get-optimistic-txlog-effects.js +379 -0
- package/src/tx-log/index.js +5 -3
- package/src/tx-log/monitor-utils/check-pending-transactions.js +14 -5
- package/src/tx-log/monitor-utils/exclude-unchanged-token-balances.js +1 -1
- package/src/tx-log/monitor-utils/extract-balance-from-clarity-account-info.js +108 -0
- package/src/tx-log/monitor-utils/get-balance-updates.js +44 -0
- package/src/tx-log/monitor-utils/get-batched-rpc-balances.js +47 -0
- package/src/tx-log/monitor-utils/get-current-blacklist-status.js +38 -0
- package/src/tx-log/monitor-utils/get-current-eip7702-delegation.js +79 -0
- package/src/tx-log/monitor-utils/get-derive-transactions-to-check.js +10 -5
- package/src/tx-log/monitor-utils/get-fee-amount.js +1 -1
- package/src/tx-log/monitor-utils/get-history-from-server.js +1 -1
- package/src/tx-log/monitor-utils/get-log-items-from-server-tx.js +75 -33
- package/src/tx-log/monitor-utils/get-names-of-tokens-transferred-by-server-tx.js +3 -2
- package/src/tx-log/monitor-utils/get-raw-transfer-amount.js +29 -0
- package/src/tx-log/monitor-utils/get-transfers-by-token-name.js +3 -2
- package/src/tx-log/monitor-utils/get-value-of-transfers.js +10 -12
- package/src/tx-log/monitor-utils/index.js +12 -7
- 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 +90 -0
- package/src/tx-log-staking-processor/asset-staking-tx-data.js +82 -24
- package/src/tx-log-staking-processor/index.js +28 -14
- package/src/tx-log-staking-processor/utils.js +21 -20
- package/src/tx-send/broadcast-error-handler.js +63 -0
- package/src/tx-send/index.js +1 -2
- package/src/tx-send/nonce-utils.js +97 -0
- package/src/tx-send/tx-send.js +94 -288
- package/src/tx-sign/index.js +17 -0
- package/src/tx-type/index.js +280 -0
- package/src/watch-tx.js +36 -0
- package/src/web3/createSimulateMessage.js +3 -2
- package/src/web3/createSimulateTransactions.js +11 -0
- package/src/web3/index.js +3 -1
- package/src/etherscan/account.js +0 -49
- package/src/etherscan/index.js +0 -28
- package/src/etherscan/logs.js +0 -16
- package/src/etherscan/proxy.js +0 -47
- package/src/etherscan/request.js +0 -26
- package/src/etherscan/ws.js +0 -88
- package/src/exodus-eth-server/server-factory.js +0 -28
- package/src/exodus-eth-server/server-map.js +0 -21
- package/src/get-fee-async.js +0 -97
- package/src/staking/matic/service.js +0 -437
- package/src/staking-api.js +0 -5
- package/src/tx-log/clarity-utils/get-derive-data-needed-for-tick.js +0 -12
- package/src/tx-log-staking-processor/get-asset-tx-amount.js +0 -26
- package/src/tx-send/get-fee-info.js +0 -41
- package/src/websocket/index.android.js +0 -2
- package/src/websocket/index.ios.js +0 -2
- package/src/websocket/index.js +0 -21
package/src/get-fee.js
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
calculateBumpedGasPrice,
|
|
3
|
+
calculateBumpedGasPriceForFeeData,
|
|
4
|
+
calculateExtraEth,
|
|
5
|
+
isEthereumLikeToken,
|
|
6
|
+
} from '@exodus/ethereum-lib'
|
|
7
|
+
import assert from 'minimalistic-assert'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ensureSaneEip1559GasPriceForTipGasPrice,
|
|
11
|
+
getNormalizedFeeDataForCustomFee,
|
|
12
|
+
resolveGasPrice,
|
|
13
|
+
} from './fee-utils.js'
|
|
14
|
+
import { defaultGasLimit } from './gas-estimation.js'
|
|
2
15
|
|
|
3
16
|
// Move to meta?
|
|
4
17
|
const taxes = {
|
|
5
18
|
paxgold: 0.0002,
|
|
6
19
|
}
|
|
7
20
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// if eip1559 enabled, do not risk not leaving enough ETH to cover base fee (applies only for native asset)
|
|
13
|
-
// (gasPrice difference will be reimbursed anyway: users do not overpay)
|
|
14
|
-
if (isSendAll && isEthereumLike(asset) && feeData.eip1559Enabled) return 1
|
|
15
|
-
|
|
16
|
-
// do not risk having a stuck tx if we're not able to accelerate it
|
|
17
|
-
if (!isRbfAllowed || !feeData.rbfEnabled) return 1
|
|
18
|
-
|
|
19
|
-
return feeData.gasPriceEconomicalRate
|
|
20
|
-
}
|
|
21
|
+
export const getExtraFeeData = ({ asset, amount, txValue }) => {
|
|
22
|
+
if (isEthereumLikeToken(asset) && txValue?.gt(asset.baseAsset.currency.ZERO)) {
|
|
23
|
+
return { type: 'tax', extraFee: txValue }
|
|
24
|
+
}
|
|
21
25
|
|
|
22
|
-
const getExtraFeeData = ({ asset, amount }) => {
|
|
23
26
|
const tax = taxes[asset.name]
|
|
24
27
|
if (!amount || !tax || amount.isZero) {
|
|
25
28
|
return {}
|
|
@@ -33,62 +36,95 @@ const getExtraFeeData = ({ asset, amount }) => {
|
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
const getGasPricesResult = ({ gasPrice, feeData }) => {
|
|
40
|
+
const { tipGasPrice, eip1559Enabled } = feeData
|
|
41
|
+
return {
|
|
42
|
+
gasPrice: eip1559Enabled
|
|
43
|
+
? // NOTE: The `gasPrice` must be at least the `tipGasPrice`.
|
|
44
|
+
ensureSaneEip1559GasPriceForTipGasPrice({ gasPrice, tipGasPrice })
|
|
45
|
+
: gasPrice,
|
|
40
46
|
feeData,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
isExchange,
|
|
44
|
-
isSendAll,
|
|
45
|
-
amount,
|
|
46
|
-
isRbfAllowed = true, // Destkop, isRbfAllowed=true when advanced panel is on
|
|
47
|
-
calculateEffectiveFee,
|
|
48
|
-
}) => {
|
|
49
|
-
const { gasPrice, eip1559Enabled, baseFeePerGas, tipGasPrice } = feeData
|
|
50
|
-
const gasPriceMultiplier = getGasPriceMultiplier({
|
|
51
|
-
asset,
|
|
52
|
-
isRbfAllowed,
|
|
53
|
-
feeData,
|
|
54
|
-
isExchange,
|
|
55
|
-
isSendAll,
|
|
56
|
-
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
57
49
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const maxFeePerGas = customFee || gasPrice
|
|
61
|
-
// effective_gas_price = min(base_fee_per_gas + tip_gas_price, max_fee_per_gas)
|
|
62
|
-
const feePerGas = baseFeePerGas.add(tipGasPrice)
|
|
63
|
-
const effectiveGasPrice = feePerGas.lt(maxFeePerGas) ? feePerGas : maxFeePerGas
|
|
50
|
+
const getGasPricesForTxToReplace = ({ baseAsset, feeData, txToReplace }) => {
|
|
51
|
+
const { eip1559Enabled } = feeData
|
|
64
52
|
|
|
65
|
-
|
|
66
|
-
|
|
53
|
+
const effectiveGasPrice = resolveGasPrice({ feeData })
|
|
54
|
+
const { tipGasPrice: effectiveTipGasPrice } = feeData
|
|
67
55
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
56
|
+
const { bumpedGasPrice, bumpedTipGasPrice } = calculateBumpedGasPriceForFeeData({
|
|
57
|
+
baseAsset,
|
|
58
|
+
feeData,
|
|
59
|
+
tx: txToReplace,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const gasPrice = bumpedGasPrice.gt(effectiveGasPrice) ? bumpedGasPrice : effectiveGasPrice
|
|
63
|
+
if (!eip1559Enabled) return getGasPricesResult({ gasPrice, feeData })
|
|
64
|
+
|
|
65
|
+
const tipGasPrice = bumpedTipGasPrice.gt(effectiveTipGasPrice)
|
|
66
|
+
? bumpedTipGasPrice
|
|
67
|
+
: effectiveTipGasPrice
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
return getGasPricesResult({
|
|
70
|
+
gasPrice,
|
|
71
|
+
feeData: { ...feeData, tipGasPrice },
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const getAggregateTransactionPricing = ({ baseAsset, customFee, feeData, txToReplace }) => {
|
|
76
|
+
assert(baseAsset, 'expected baseAsset')
|
|
75
77
|
|
|
76
|
-
if (
|
|
77
|
-
return
|
|
78
|
+
if (customFee) {
|
|
79
|
+
return getGasPricesResult({
|
|
80
|
+
gasPrice: customFee,
|
|
81
|
+
feeData: getNormalizedFeeDataForCustomFee({ customFee, feeData }),
|
|
82
|
+
})
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
if (txToReplace) return getGasPricesForTxToReplace({ baseAsset, feeData, txToReplace })
|
|
86
|
+
|
|
87
|
+
return getGasPricesResult({
|
|
88
|
+
gasPrice: resolveGasPrice({ feeData }),
|
|
89
|
+
feeData,
|
|
90
|
+
})
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
export const getFeeFactory =
|
|
94
|
+
() =>
|
|
95
|
+
({ asset, feeData, customFee, txInput, gasLimit: providedGasLimit, amount, txValue }) => {
|
|
96
|
+
const {
|
|
97
|
+
feeData: { tipGasPrice, eip1559Enabled },
|
|
98
|
+
gasPrice,
|
|
99
|
+
} = getAggregateTransactionPricing({
|
|
100
|
+
baseAsset: asset.baseAsset,
|
|
101
|
+
customFee,
|
|
102
|
+
feeData,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const gasLimit = providedGasLimit ?? defaultGasLimit({ asset, txInput })
|
|
106
|
+
|
|
107
|
+
// When explicitly opting into EIP-1559 transactions,
|
|
108
|
+
// lock in the `tipGasPrice` we used to compute the fees.
|
|
109
|
+
const maybeReturnTipGasPrice = eip1559Enabled ? { tipGasPrice } : null
|
|
110
|
+
|
|
111
|
+
const extraFeeData = getExtraFeeData({ asset, amount, txValue })
|
|
112
|
+
|
|
113
|
+
const fee = gasPrice.mul(gasLimit)
|
|
114
|
+
return { ...maybeReturnTipGasPrice, fee, gasPrice, extraFeeData }
|
|
115
|
+
}
|
|
116
|
+
|
|
84
117
|
// Used in Mobile
|
|
85
|
-
export const getExtraFeeForBump = ({ tx, feeData, balance, unconfirmedBalance }) => {
|
|
118
|
+
export const getExtraFeeForBump = ({ baseAsset, tx, feeData, balance, unconfirmedBalance }) => {
|
|
119
|
+
assert(baseAsset, 'expected baseAsset')
|
|
120
|
+
|
|
86
121
|
if (!balance || !unconfirmedBalance) return null
|
|
87
|
-
const { gasPrice: currentGasPrice, eip1559Enabled } = feeData
|
|
122
|
+
const { gasPrice: currentGasPrice, eip1559Enabled, baseFeePerGas: currentBaseFee } = feeData
|
|
88
123
|
const { bumpedGasPrice } = calculateBumpedGasPrice({
|
|
89
|
-
baseAsset
|
|
124
|
+
baseAsset,
|
|
90
125
|
tx,
|
|
91
126
|
currentGasPrice,
|
|
127
|
+
currentBaseFee,
|
|
92
128
|
eip1559Enabled,
|
|
93
129
|
})
|
|
94
130
|
return calculateExtraEth({
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { TxSet } from '@exodus/models'
|
|
2
|
+
import assert from 'minimalistic-assert'
|
|
3
|
+
|
|
4
|
+
import { getLatestCanonicalAbsoluteBalanceTx } from './tx-log/clarity-utils/index.js'
|
|
5
|
+
|
|
6
|
+
const walkBackFromBalance = ({ txLog, deadline, balance }) => {
|
|
7
|
+
let result = balance
|
|
8
|
+
|
|
9
|
+
for (const tx of txLog) {
|
|
10
|
+
if (new Date(tx.date).getTime() <= deadline) continue
|
|
11
|
+
if (tx.failed || tx.dropped || tx.pending || tx.data?.replacedBy) continue
|
|
12
|
+
|
|
13
|
+
result = result.sub(tx.coinAmount)
|
|
14
|
+
if (tx.feeAmount?.unitType.equals(tx.coinAmount.unitType)) {
|
|
15
|
+
result = result.add(tx.feeAmount)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return result.clampLowerZero()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds the Ethereum-like balance as of `date`. Anchors on the latest Clarity
|
|
24
|
+
* absolute-balance snapshot at or before the date, falling back to a walk-back
|
|
25
|
+
* from the current balance when no snapshot is available.
|
|
26
|
+
*/
|
|
27
|
+
export const getHistoricalBalanceFactory = ({ assetClientInterface, getBalances }) => {
|
|
28
|
+
assert(assetClientInterface, 'assetClientInterface is required')
|
|
29
|
+
assert(getBalances, 'getBalances is required')
|
|
30
|
+
|
|
31
|
+
return async ({ asset, walletAccount, date }) => {
|
|
32
|
+
assert(asset, 'asset is required')
|
|
33
|
+
assert(walletAccount, 'walletAccount is required')
|
|
34
|
+
assert(date instanceof Date, 'date is required')
|
|
35
|
+
|
|
36
|
+
const txLog = await assetClientInterface.getTxLog({ assetName: asset.name, walletAccount })
|
|
37
|
+
const deadline = date.getTime()
|
|
38
|
+
const anchor = getLatestCanonicalAbsoluteBalanceTx({ reversedTxLog: txLog.reverse(), deadline })
|
|
39
|
+
|
|
40
|
+
if (anchor) {
|
|
41
|
+
const total = asset.currency.baseUnit(anchor.data.balanceChange.to)
|
|
42
|
+
return { total, entries: [{ amount: total, date: deadline }] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const accountState = await assetClientInterface.getAccountState({
|
|
46
|
+
assetName: asset.baseAsset.name,
|
|
47
|
+
walletAccount,
|
|
48
|
+
})
|
|
49
|
+
const confirmedTxLog = TxSet.fromArray([...txLog].filter((tx) => !tx.pending))
|
|
50
|
+
const balance = getBalances({ asset, accountState, txLog: confirmedTxLog }).spendable
|
|
51
|
+
const total = walkBackFromBalance({ txLog: confirmedTxLog, deadline, balance })
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
total,
|
|
55
|
+
entries: [{ amount: total, date: deadline }],
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/hooks/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createEthereumHooks } from './monitor'
|
|
1
|
+
export { createEthereumHooks } from './monitor.js'
|
package/src/hooks/monitor.js
CHANGED
|
@@ -1,72 +1,211 @@
|
|
|
1
|
-
import { getEthereumStakingInfo, getPolygonStakingInfo } from '../staking'
|
|
2
|
-
import processTxLog from '../tx-log-staking-processor'
|
|
3
1
|
import assert from 'minimalistic-assert'
|
|
4
2
|
|
|
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
|
+
])
|
|
27
|
+
|
|
28
|
+
const isPlainObject = (value) =>
|
|
29
|
+
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
30
|
+
|
|
5
31
|
export const createEthereumHooks = ({
|
|
6
32
|
assetClientInterface,
|
|
7
33
|
assetName,
|
|
8
|
-
|
|
34
|
+
stakingConfiguration,
|
|
9
35
|
server,
|
|
36
|
+
now = () => performance.now(),
|
|
10
37
|
}) => {
|
|
11
38
|
assert(assetClientInterface, 'assetClientInterface is required')
|
|
12
39
|
assert(server, 'server is required')
|
|
13
40
|
assert(assetName, 'assetName is required')
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
assert(isPlainObject(stakingConfiguration), 'object stakingConfiguration is required')
|
|
42
|
+
const stakingAssetNames = Object.keys(stakingConfiguration)
|
|
43
|
+
for (const name of stakingAssetNames) {
|
|
44
|
+
const config = stakingConfiguration[name]
|
|
45
|
+
assert(isPlainObject(config), `expected object for ${name} staking configuration`)
|
|
46
|
+
const contracts = config.activityContracts
|
|
47
|
+
assert(
|
|
48
|
+
contracts === undefined ||
|
|
49
|
+
(Array.isArray(contracts) && contracts.every((address) => typeof address === 'string')),
|
|
50
|
+
`expected array of strings for ${name} activityContracts`
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
const freshness = new Map()
|
|
54
|
+
const addresses = new Map()
|
|
55
|
+
const generations = new Map()
|
|
56
|
+
const commits = new Map()
|
|
57
|
+
const afterTickHook = async ({ walletAccount, refresh = false }) => {
|
|
58
|
+
// Order requests at entry, not after address discovery: discovery itself can
|
|
59
|
+
// be delayed while a newer explicit refresh is already running.
|
|
60
|
+
const accountKey = String(walletAccount)
|
|
61
|
+
const generation = {}
|
|
62
|
+
generations.set(accountKey, generation)
|
|
63
|
+
const assets = await assetClientInterface.getAssetsForNetwork({ baseAssetName: assetName })
|
|
64
|
+
const stakingAssets = stakingAssetNames.map((name) => assets[name]).filter(Boolean)
|
|
23
65
|
const userAddress = await assetClientInterface.getReceiveAddress({
|
|
24
66
|
assetName,
|
|
25
67
|
walletAccount,
|
|
26
68
|
useCache: true,
|
|
27
69
|
})
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
70
|
+
const address = userAddress.toString().toLowerCase()
|
|
71
|
+
if (generations.get(accountKey) !== generation) return
|
|
72
|
+
const addressChanged = addresses.get(accountKey) !== address
|
|
73
|
+
addresses.set(accountKey, address)
|
|
74
|
+
const baseTxLog = await assetClientInterface.getTxLog({ assetName, walletAccount })
|
|
75
|
+
const patches = Object.create(null)
|
|
76
|
+
const completed = []
|
|
32
77
|
|
|
33
78
|
for (const asset of stakingAssets) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
79
|
+
const stakingApi = asset.api?.staking
|
|
80
|
+
if (!stakingApi?.getStakingInfo) continue
|
|
81
|
+
const key = JSON.stringify([String(walletAccount), assetName, asset.name, address])
|
|
82
|
+
const previous = freshness.get(key)
|
|
83
|
+
try {
|
|
84
|
+
const txLog =
|
|
85
|
+
asset.name === assetName
|
|
86
|
+
? baseTxLog
|
|
87
|
+
: await assetClientInterface.getTxLog({ assetName: asset.name, walletAccount })
|
|
88
|
+
const { pending, fingerprint } = getStakingActivity({
|
|
89
|
+
assetName: asset.name,
|
|
90
|
+
activityContracts: stakingConfiguration[asset.name].activityContracts,
|
|
91
|
+
txLogs: [baseTxLog, txLog],
|
|
92
|
+
})
|
|
93
|
+
const force =
|
|
94
|
+
refresh || addressChanged || pending || !previous || previous.fingerprint !== fingerprint
|
|
95
|
+
if (!force && now() < previous.deadline) continue
|
|
37
96
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
97
|
+
patches[asset.name] = await stakingApi.getStakingInfo({
|
|
98
|
+
address,
|
|
99
|
+
background: true,
|
|
100
|
+
useCache: !force,
|
|
101
|
+
// Initial per-address discovery is full, but the queue is global.
|
|
102
|
+
useQueueCache:
|
|
103
|
+
!refresh && !pending && (!previous || previous.fingerprint === fingerprint),
|
|
104
|
+
})
|
|
105
|
+
completed.push([
|
|
106
|
+
key,
|
|
107
|
+
{ fingerprint, deadline: now() + stakingStateInterval(patches[asset.name]) },
|
|
108
|
+
])
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Keep the last successful account state and retry on the next normal
|
|
111
|
+
// tick. Never interpret transport/contract/provider failures as zero.
|
|
112
|
+
if (generations.get(accountKey) === generation) freshness.delete(key)
|
|
113
|
+
console.warn(`Failed to refresh ${asset.name} staking state`, error)
|
|
114
|
+
}
|
|
45
115
|
}
|
|
46
116
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
batch
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
117
|
+
const publish = async () => {
|
|
118
|
+
if (generations.get(accountKey) !== generation) return
|
|
119
|
+
const batch = assetClientInterface.createOperationsBatch()
|
|
120
|
+
// All asynchronous preparation is inside the per-account commit queue.
|
|
121
|
+
// Newer reads can run concurrently, but cannot commit ahead of an already
|
|
122
|
+
// executing batch. A superseded preparation is discarded before execute.
|
|
123
|
+
const current = await assetClientInterface.getAccountState({ assetName, walletAccount })
|
|
124
|
+
await Promise.all(
|
|
125
|
+
stakingAssets.map((asset) =>
|
|
126
|
+
processTxLog({
|
|
127
|
+
asset,
|
|
128
|
+
assetClientInterface,
|
|
129
|
+
walletAccount,
|
|
130
|
+
batch,
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
const latestAddress = await assetClientInterface.getReceiveAddress({
|
|
135
|
+
assetName,
|
|
59
136
|
walletAccount,
|
|
60
|
-
|
|
61
|
-
server,
|
|
137
|
+
useCache: true,
|
|
62
138
|
})
|
|
63
|
-
|
|
139
|
+
if (
|
|
140
|
+
latestAddress.toString().toLowerCase() !== address ||
|
|
141
|
+
generations.get(accountKey) !== generation
|
|
142
|
+
) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
64
145
|
|
|
65
|
-
|
|
66
|
-
|
|
146
|
+
const staking = { ...current.staking }
|
|
147
|
+
const owners = { ...current.stakingSnapshotOwners }
|
|
148
|
+
let changed = false
|
|
149
|
+
// Ownership is persisted with the snapshot, not inferred from in-memory
|
|
150
|
+
// deadlines. Unknown legacy owners cannot supply failed fields on restore.
|
|
151
|
+
// Preserve only non-service host metadata when changing snapshot owners.
|
|
152
|
+
for (const name of new Set([...stakingAssetNames, ...Object.keys(owners)])) {
|
|
153
|
+
if (owners[name]?.address !== address || owners[name]?.network !== assetName) {
|
|
154
|
+
staking[name] = Object.fromEntries(
|
|
155
|
+
Object.entries(staking[name] || {}).filter(([key]) => !STAKING_SNAPSHOT_FIELDS.has(key))
|
|
156
|
+
)
|
|
157
|
+
delete owners[name]
|
|
158
|
+
changed = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const [name, patch] of Object.entries(patches)) {
|
|
162
|
+
staking[name] = { ...staking[name], ...patch }
|
|
163
|
+
owners[name] = { address, network: assetName }
|
|
164
|
+
changed = true
|
|
165
|
+
}
|
|
166
|
+
if (changed) {
|
|
167
|
+
assetClientInterface.updateAccountStateBatch({
|
|
168
|
+
assetName,
|
|
169
|
+
walletAccount,
|
|
170
|
+
newData: { staking, stakingSnapshotOwners: owners },
|
|
171
|
+
batch,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
// No await between the final generation/address check and batch execution.
|
|
175
|
+
await assetClientInterface.executeOperationsBatch(batch)
|
|
176
|
+
// An in-flight storage commit cannot be cancelled through ACI. It is
|
|
177
|
+
// serialized before newer commits; never let it publish a stale deadline.
|
|
178
|
+
const committedAddress = await assetClientInterface.getReceiveAddress({
|
|
179
|
+
assetName,
|
|
180
|
+
walletAccount,
|
|
181
|
+
useCache: true,
|
|
182
|
+
})
|
|
183
|
+
if (
|
|
184
|
+
generations.get(accountKey) === generation &&
|
|
185
|
+
committedAddress.toString().toLowerCase() === address
|
|
186
|
+
) {
|
|
187
|
+
for (const [key, entry] of completed) freshness.set(key, entry)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const previousCommit = commits.get(accountKey) || Promise.resolve()
|
|
191
|
+
const commit = previousCommit.catch(() => {}).then(publish)
|
|
192
|
+
commits.set(accountKey, commit)
|
|
193
|
+
try {
|
|
194
|
+
await commit
|
|
195
|
+
} finally {
|
|
196
|
+
if (commits.get(accountKey) === commit) commits.delete(accountKey)
|
|
197
|
+
}
|
|
67
198
|
}
|
|
68
199
|
|
|
69
200
|
return {
|
|
201
|
+
// A restart/resume exposed through monitor.start must rediscover restored
|
|
202
|
+
// accounts. No host screen lifecycle is assumed; update({refresh:true}) and
|
|
203
|
+
// direct getStakingInfo({address}) are explicit uncached refresh paths.
|
|
204
|
+
'before-start': () => {
|
|
205
|
+
freshness.clear()
|
|
206
|
+
addresses.clear()
|
|
207
|
+
generations.clear()
|
|
208
|
+
},
|
|
70
209
|
'after-tick': afterTickHook,
|
|
71
210
|
}
|
|
72
211
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isPendingTxInLog } from '@exodus/ethereum-lib'
|
|
2
|
+
|
|
3
|
+
const STAKING_FIELDS = ['delegate', 'undelegate', 'undelegatePending', 'claimUndelegate', 'rewards']
|
|
4
|
+
|
|
5
|
+
// Inspect both logs: Polygon calls without token transfers only live in ETH's
|
|
6
|
+
// log. Any interaction with a staking contract is conservatively relevant.
|
|
7
|
+
export const getStakingActivity = ({ assetName, activityContracts = [], txLogs }) => {
|
|
8
|
+
const contracts = new Set(activityContracts.map((address) => address.toLowerCase()))
|
|
9
|
+
const isContract = (address) =>
|
|
10
|
+
typeof address === 'string' && contracts.has(address.toLowerCase())
|
|
11
|
+
const entries = new Map()
|
|
12
|
+
let pending = false
|
|
13
|
+
for (const txs of txLogs) {
|
|
14
|
+
for (const tx of txs) {
|
|
15
|
+
const relevant =
|
|
16
|
+
isContract(tx.to) ||
|
|
17
|
+
(Array.isArray(tx.from) ? tx.from.some(isContract) : isContract(tx.from)) ||
|
|
18
|
+
(tx.coinName === assetName &&
|
|
19
|
+
STAKING_FIELDS.some((field) => tx.data?.[field] !== undefined))
|
|
20
|
+
if (!relevant) continue
|
|
21
|
+
const isPending = isPendingTxInLog(tx)
|
|
22
|
+
pending ||= isPending
|
|
23
|
+
// Do not include increasing confirmation counts or processor-added metadata:
|
|
24
|
+
// neither constitutes a new staking event. Include block identity for reorgs.
|
|
25
|
+
entries.set(`${tx.coinName}:${tx.txId}`, [
|
|
26
|
+
tx.txId,
|
|
27
|
+
isPending,
|
|
28
|
+
tx.confirmations > 0,
|
|
29
|
+
tx.error || null,
|
|
30
|
+
!!tx.dropped,
|
|
31
|
+
tx.data?.replacedBy || null,
|
|
32
|
+
tx.data?.blockHash || tx.blockHash || null,
|
|
33
|
+
tx.data?.blockNumber || tx.blockHeight || null,
|
|
34
|
+
])
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
pending,
|
|
39
|
+
fingerprint: JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b))),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const stakingStateInterval = (info) => {
|
|
44
|
+
// A pending validator activation/exit is chain state, not a pending local tx.
|
|
45
|
+
// It must never keep a wallet on the one-minute transaction cadence forever.
|
|
46
|
+
const active =
|
|
47
|
+
info.isDelegating ||
|
|
48
|
+
info.isUndelegateInProgress ||
|
|
49
|
+
info.canClaimUndelegatedBalance ||
|
|
50
|
+
[
|
|
51
|
+
'delegatedBalance',
|
|
52
|
+
'pendingBalance',
|
|
53
|
+
'pendingDepositedBalance',
|
|
54
|
+
'unclaimedUndelegatedBalance',
|
|
55
|
+
'liquidRewards',
|
|
56
|
+
].some((key) => info[key]?.isPositive)
|
|
57
|
+
return (active ? 5 : 15) * 60_000
|
|
58
|
+
}
|