@exodus/sui-api 1.2.3 → 1.3.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 +25 -0
- package/package.json +4 -7
- package/src/api.js +17 -0
- package/src/balances.js +9 -2
- package/src/basic-account-state.js +3 -0
- package/src/index.js +4 -0
- package/src/staking/constants.js +18 -0
- package/src/staking/create-staking-api.js +441 -0
- package/src/staking/staking-balances.js +19 -0
- package/src/staking/staking-errors.js +27 -0
- package/src/staking/staking-info.js +68 -0
- package/src/staking/staking-provider-client.js +57 -0
- package/src/staking/staking-settings.js +39 -0
- package/src/staking/staking-transactions.js +88 -0
- package/src/sui-no-history-monitor.js +41 -2
- package/src/transform-tx.js +7 -1
- package/src/tx-send.js +21 -4
- package/src/vendors/experimental/cache.js +2 -1
- package/src/vendors/experimental/transports/json-rpc-resolver.js +8 -11
- package/src/vendors/transactions/Commands.js +1 -1
- package/src/vendors/transactions/Transaction.js +4 -2
- package/src/vendors/transactions/TransactionData.js +1 -1
- package/src/vendors/transactions/hash.js +0 -2
- package/src/vendors/transactions/resolve.js +1 -1
- package/src/vendors/valibot.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,31 @@
|
|
|
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
|
+
## [1.3.1](https://github.com/ExodusMovement/assets/compare/@exodus/sui-api@1.3.0...@exodus/sui-api@1.3.1) (2026-10-09)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* migrate to @exodus/basic-utils 5 ([#8894](https://github.com/ExodusMovement/assets/issues/8894)) ([ed125f3](https://github.com/ExodusMovement/assets/commit/ed125f3257785c92a3b8f8239a2e9eefe5dc6772))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## [1.3.0](https://github.com/ExodusMovement/assets/compare/@exodus/sui-api@1.2.3...@exodus/sui-api@1.3.0) (2026-10-05)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **sui-plugin:** sui native staking via staking api v2 ([#8839](https://github.com/ExodusMovement/assets/issues/8839)) ([8523a23](https://github.com/ExodusMovement/assets/commit/8523a2303deeaa4dc4280829fb5b642191a3ae2e))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* 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))
|
|
26
|
+
* **lint:** fix no-await-expression-member warnings and upgrade to error ([#8503](https://github.com/ExodusMovement/assets/issues/8503)) ([a7fb2c3](https://github.com/ExodusMovement/assets/commit/a7fb2c3bcb4e6b9e97e7f8bb3b8434b78036fe68))
|
|
27
|
+
* **lint:** migrate lodash to @exodus/basic-utils ([#8514](https://github.com/ExodusMovement/assets/issues/8514)) ([12c574f](https://github.com/ExodusMovement/assets/commit/12c574f1ac8ab0592700df574b2643141237090a))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
6
31
|
## [1.2.3](https://github.com/ExodusMovement/assets/compare/@exodus/sui-api@1.2.2...@exodus/sui-api@1.2.3) (2026-08-06)
|
|
7
32
|
|
|
8
33
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/sui-api",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.2.3",
|
|
3
|
+
"version": "1.3.1",
|
|
5
4
|
"description": "Transaction monitors, RPC with the blockchain node, and other networking code for Sui",
|
|
6
5
|
"type": "module",
|
|
7
6
|
"main": "src/index.js",
|
|
@@ -19,14 +18,12 @@
|
|
|
19
18
|
"provenance": false
|
|
20
19
|
},
|
|
21
20
|
"scripts": {
|
|
22
|
-
"test": "
|
|
23
|
-
"lint": "run -T eslintc .",
|
|
24
|
-
"lint:fix": "yarn lint --fix"
|
|
21
|
+
"test": "exodus-test --jest"
|
|
25
22
|
},
|
|
26
23
|
"dependencies": {
|
|
27
24
|
"@exodus/asset-lib": "^5.0.0",
|
|
28
25
|
"@exodus/assets": "^11.1.2",
|
|
29
|
-
"@exodus/basic-utils": "^
|
|
26
|
+
"@exodus/basic-utils": "^5.1.0",
|
|
30
27
|
"@exodus/crypto": "^1.0.0-rc.26",
|
|
31
28
|
"@exodus/currency": "^6.0.1",
|
|
32
29
|
"@exodus/models": "^13.0.0",
|
|
@@ -50,5 +47,5 @@
|
|
|
50
47
|
"type": "git",
|
|
51
48
|
"url": "git+https://github.com/ExodusMovement/assets.git"
|
|
52
49
|
},
|
|
53
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "41e6c1bb6a3d919f08cc3ee38068159259b335be"
|
|
54
51
|
}
|
package/src/api.js
CHANGED
|
@@ -184,11 +184,28 @@ export class SuiApi {
|
|
|
184
184
|
return coins
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
async getStakes({ owner }) {
|
|
188
|
+
return this.client.getStakes({ owner })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async getLatestSuiSystemState() {
|
|
192
|
+
return this.client.getLatestSuiSystemState()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async buildTransaction({ transaction, onlyTransactionKind = false }) {
|
|
196
|
+
return transaction.build({ client: this.client, onlyTransactionKind })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async devInspectTransaction({ sender, transactionBlock }) {
|
|
200
|
+
return this.client.devInspectTransactionBlock({ sender, transactionBlock })
|
|
201
|
+
}
|
|
202
|
+
|
|
187
203
|
async broadcastTx(plainTx) {
|
|
188
204
|
return this.client.executeTransactionBlock({
|
|
189
205
|
transactionBlock: plainTx.bytes,
|
|
190
206
|
signature: plainTx.signature,
|
|
191
207
|
options: {
|
|
208
|
+
showInput: true,
|
|
192
209
|
showEffects: true,
|
|
193
210
|
showObjectChanges: true,
|
|
194
211
|
showBalanceChanges: true,
|
package/src/balances.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
import { getStakingBalances } from './staking/staking-balances.js'
|
|
2
|
+
|
|
1
3
|
export const getBalances = ({ asset, accountState }) => {
|
|
2
4
|
const isBaseAsset = asset.name === asset.baseAssetName
|
|
3
5
|
|
|
4
6
|
if (isBaseAsset) {
|
|
7
|
+
const spendable = accountState.utxos.value
|
|
8
|
+
const stakingBalances = getStakingBalances({ asset, stakingInfo: accountState.stakingInfo })
|
|
9
|
+
const { staking, staked, unstaked } = stakingBalances
|
|
5
10
|
return {
|
|
6
|
-
total:
|
|
7
|
-
spendable
|
|
11
|
+
total: spendable.add(staking).add(staked).add(unstaked),
|
|
12
|
+
spendable,
|
|
13
|
+
stakeable: spendable,
|
|
14
|
+
...stakingBalances,
|
|
8
15
|
}
|
|
9
16
|
}
|
|
10
17
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { AccountState, UtxoCollection } from '@exodus/models'
|
|
2
2
|
import { asset } from '@exodus/sui-meta'
|
|
3
3
|
|
|
4
|
+
import { EMPTY_STAKING_INFO } from './staking/staking-info.js'
|
|
5
|
+
|
|
4
6
|
export const createBasicAccountState = () => {
|
|
5
7
|
return class SuiAccountState extends AccountState {
|
|
6
8
|
static defaults = {
|
|
7
9
|
utxos: UtxoCollection.createEmpty({ currency: asset.currency }),
|
|
8
10
|
tokenUtxos: Object.create(null),
|
|
11
|
+
stakingInfo: EMPTY_STAKING_INFO,
|
|
9
12
|
}
|
|
10
13
|
}
|
|
11
14
|
}
|
package/src/index.js
CHANGED
|
@@ -8,3 +8,7 @@ export { getFeeAsyncFactory } from './get-fee-async.js'
|
|
|
8
8
|
export { txSendFactory } from './tx-send.js'
|
|
9
9
|
export { createUnsignedTxFactory } from './create-unsigned-tx.js'
|
|
10
10
|
export { createTxFactory } from './tx-create.js'
|
|
11
|
+
export { createStakingSettings } from './staking/staking-settings.js'
|
|
12
|
+
export { EVERSTAKE_VALIDATOR } from './staking/constants.js'
|
|
13
|
+
export { createStakingApi } from './staking/create-staking-api.js'
|
|
14
|
+
export { createStakingProviderClient } from './staking/staking-provider-client.js'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import ms from 'ms'
|
|
2
|
+
|
|
3
|
+
export const EVERSTAKE_VALIDATOR =
|
|
4
|
+
'0xbba318294a51ddeafa50c335c8e77202170e1f272599a2edc40592100863f638'
|
|
5
|
+
export const EVERSTAKE_VALIDATOR_NAME = 'Everstake'
|
|
6
|
+
|
|
7
|
+
export const SUI_SYSTEM_PACKAGE = '0x3'
|
|
8
|
+
export const SUI_SYSTEM_MODULE = 'sui_system'
|
|
9
|
+
export const ADD_STAKE_FUNCTION = 'request_add_stake'
|
|
10
|
+
export const WITHDRAW_STAKE_FUNCTION = 'request_withdraw_stake'
|
|
11
|
+
export const ADD_STAKE_TARGET = `${SUI_SYSTEM_PACKAGE}::${SUI_SYSTEM_MODULE}::${ADD_STAKE_FUNCTION}`
|
|
12
|
+
export const WITHDRAW_STAKE_TARGET = `${SUI_SYSTEM_PACKAGE}::${SUI_SYSTEM_MODULE}::${WITHDRAW_STAKE_FUNCTION}`
|
|
13
|
+
export const STAKED_SUI_TYPE = '0x3::staking_pool::StakedSui'
|
|
14
|
+
|
|
15
|
+
export const STAKING_GAS_BUDGET = 20_000_000
|
|
16
|
+
export const EPOCH_MS = ms('1d')
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_STAKING_URL = 'https://staking.a.exodus.io'
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { GAS_BUDGET_SAFETY_FACTOR, MIN_GAS_BUDGET } from '@exodus/sui-lib'
|
|
2
|
+
import assert from 'minimalistic-assert'
|
|
3
|
+
import ms from 'ms'
|
|
4
|
+
|
|
5
|
+
import { getFeeAmount } from '../transform-tx.js'
|
|
6
|
+
import { EPOCH_MS, EVERSTAKE_VALIDATOR_NAME, STAKING_GAS_BUDGET } from './constants.js'
|
|
7
|
+
import { getStakeValue } from './staking-balances.js'
|
|
8
|
+
import { createRejectionError, createSubmitError, fail, pass } from './staking-errors.js'
|
|
9
|
+
import { getEpochStartMs } from './staking-info.js'
|
|
10
|
+
import { buildStakeTransaction, buildUnstakeTransaction } from './staking-transactions.js'
|
|
11
|
+
|
|
12
|
+
const PLAN_TTL_MS = ms('60s')
|
|
13
|
+
const SUPPORTED_OPERATIONS = new Set(['stake', 'unstake'])
|
|
14
|
+
|
|
15
|
+
const POSITION_STATUS = {
|
|
16
|
+
Pending: 'activating',
|
|
17
|
+
Active: 'active',
|
|
18
|
+
Unstaked: 'withdrawable',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sum = ({ currency, amounts }) =>
|
|
22
|
+
amounts.reduce((total, amount) => total.add(amount), currency.ZERO)
|
|
23
|
+
|
|
24
|
+
export const createStakingApi = ({
|
|
25
|
+
assetClientInterface,
|
|
26
|
+
serverApi,
|
|
27
|
+
getBalances,
|
|
28
|
+
stakingSettings,
|
|
29
|
+
notify,
|
|
30
|
+
}) => {
|
|
31
|
+
assert(assetClientInterface, '`assetClientInterface` required')
|
|
32
|
+
assert(serverApi, '`serverApi` required')
|
|
33
|
+
assert(getBalances, '`getBalances` required')
|
|
34
|
+
assert(stakingSettings, '`stakingSettings` required')
|
|
35
|
+
|
|
36
|
+
const toValidatorTarget = (id) => ({
|
|
37
|
+
kind: 'validator',
|
|
38
|
+
id,
|
|
39
|
+
...(id === stakingSettings.getValidator() && { name: EVERSTAKE_VALIDATOR_NAME }),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const getCapabilities = ({ asset }) => ({
|
|
43
|
+
participation: 'amount',
|
|
44
|
+
liquid: false,
|
|
45
|
+
positions: 'multiple',
|
|
46
|
+
operations: {
|
|
47
|
+
stake: { amount: 'required', supportsMax: true, positionSelectable: false, multiTx: false },
|
|
48
|
+
unstake: { amount: 'none', supportsMax: false, positionSelectable: true, multiTx: false },
|
|
49
|
+
},
|
|
50
|
+
target: {
|
|
51
|
+
kind: 'validator',
|
|
52
|
+
selection: 'fixed',
|
|
53
|
+
default: toValidatorTarget(stakingSettings.getValidator()),
|
|
54
|
+
},
|
|
55
|
+
unbonding: { kind: 'instant' },
|
|
56
|
+
claimUnstaked: 'none',
|
|
57
|
+
rewards: {
|
|
58
|
+
distribution: 'autoCompound',
|
|
59
|
+
approxFirstRewardMs: 2 * EPOCH_MS,
|
|
60
|
+
approxIntervalMs: EPOCH_MS,
|
|
61
|
+
},
|
|
62
|
+
activation: { approxDurationMs: EPOCH_MS },
|
|
63
|
+
feeAssetName: asset.feeAsset.name,
|
|
64
|
+
hardwareWallets: [],
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const getMinStakingAmount = (feeData) => {
|
|
68
|
+
assert(feeData?.minStakingAmount, '`feeData.minStakingAmount` required')
|
|
69
|
+
return feeData.minStakingAmount
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const estimateFee = ({ asset }) => asset.feeAsset.currency.baseUnit(STAKING_GAS_BUDGET)
|
|
73
|
+
|
|
74
|
+
const getPendingOperation = (txLog) => {
|
|
75
|
+
if (!txLog) return
|
|
76
|
+
for (const tx of txLog) {
|
|
77
|
+
const operation = tx.data?.staking?.operation
|
|
78
|
+
if (operation && tx.pending && !tx.error) return operation
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const getAvailableAt = ({ stake, epoch }) =>
|
|
83
|
+
stake.status === 'Pending' && epoch && stake.stakeActiveEpoch !== null
|
|
84
|
+
? getEpochStartMs({ epoch, targetEpoch: stake.stakeActiveEpoch })
|
|
85
|
+
: undefined
|
|
86
|
+
|
|
87
|
+
const getNextRewardAt = ({ stakingInfo }) => {
|
|
88
|
+
const { stakes, epoch } = stakingInfo
|
|
89
|
+
if (!epoch) return
|
|
90
|
+
if (stakes.some(({ status }) => status === 'Active')) {
|
|
91
|
+
return getEpochStartMs({ epoch, targetEpoch: epoch.epoch + 1 })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const activationEpochs = stakes
|
|
95
|
+
.filter(({ status, stakeActiveEpoch }) => status === 'Pending' && stakeActiveEpoch !== null)
|
|
96
|
+
.map(({ stakeActiveEpoch }) => stakeActiveEpoch)
|
|
97
|
+
if (activationEpochs.length === 0) return
|
|
98
|
+
return getEpochStartMs({ epoch, targetEpoch: Math.min(...activationEpochs) + 1 })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const toPosition = ({ asset, stake, epoch }) => {
|
|
102
|
+
const status = POSITION_STATUS[stake.status]
|
|
103
|
+
if (!status) throw new Error(`Unknown sui stake status ${stake.status}`)
|
|
104
|
+
|
|
105
|
+
const availableAt = getAvailableAt({ stake, epoch })
|
|
106
|
+
const rewards = asset.currency.baseUnit(stake.estimatedReward)
|
|
107
|
+
return {
|
|
108
|
+
id: stake.id,
|
|
109
|
+
status,
|
|
110
|
+
amount: getStakeValue({ currency: asset.currency, stake }),
|
|
111
|
+
target: toValidatorTarget(stake.validator),
|
|
112
|
+
...(availableAt && { availableAt }),
|
|
113
|
+
...(rewards.isPositive && { rewards }),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const getStakingState = ({ asset, accountState, txLog }) => {
|
|
118
|
+
const { stakingInfo } = accountState
|
|
119
|
+
const { staking, staked, unstaking, unstaked, rewards } = getBalances({ asset, accountState })
|
|
120
|
+
const pendingOperation = getPendingOperation(txLog)
|
|
121
|
+
const nextRewardAt = getNextRewardAt({ stakingInfo })
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
loaded: stakingInfo.loaded,
|
|
125
|
+
isStaking: staking.add(staked).isPositive,
|
|
126
|
+
hasPosition: [staking, staked, unstaking, unstaked].some((amount) => amount.isPositive),
|
|
127
|
+
totals: { staking, staked, unstaking, unstaked, rewards },
|
|
128
|
+
...(nextRewardAt && { nextRewardAt }),
|
|
129
|
+
positions: stakingInfo.stakes.map((stake) =>
|
|
130
|
+
toPosition({ asset, stake, epoch: stakingInfo.epoch })
|
|
131
|
+
),
|
|
132
|
+
target: toValidatorTarget(stakingSettings.getValidator()),
|
|
133
|
+
...(pendingOperation && { pendingOperation }),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const getMaxStakeAmount = ({ asset, spendable, fee }) =>
|
|
138
|
+
spendable.sub(fee).sub(estimateFee({ asset })).clampLowerZero()
|
|
139
|
+
|
|
140
|
+
const getLimits = ({ asset, accountState, feeData, operation }) => {
|
|
141
|
+
const { spendable, staking, staked, unstaked } = getBalances({ asset, accountState })
|
|
142
|
+
const estimatedFee = estimateFee({ asset })
|
|
143
|
+
|
|
144
|
+
switch (operation) {
|
|
145
|
+
case 'stake':
|
|
146
|
+
return {
|
|
147
|
+
min: getMinStakingAmount(feeData),
|
|
148
|
+
max: getMaxStakeAmount({ asset, spendable, fee: estimatedFee }),
|
|
149
|
+
reserve: estimatedFee,
|
|
150
|
+
estimatedFee,
|
|
151
|
+
}
|
|
152
|
+
case 'unstake':
|
|
153
|
+
return { max: staking.add(staked).add(unstaked), estimatedFee }
|
|
154
|
+
default:
|
|
155
|
+
return { max: asset.currency.ZERO }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const selectStakes = ({ stakingInfo, positionIds }) => {
|
|
160
|
+
if (!positionIds) return stakingInfo.stakes
|
|
161
|
+
const selected = stakingInfo.stakes.filter(({ id }) => positionIds.includes(id))
|
|
162
|
+
return selected.length === positionIds.length ? selected : []
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const canStake = ({ asset, accountState, feeData, amount, isMax }) => {
|
|
166
|
+
assert(isMax || amount, '`amount` or `isMax` required to stake')
|
|
167
|
+
const minStakingAmount = getMinStakingAmount(feeData)
|
|
168
|
+
const { spendable } = getBalances({ asset, accountState })
|
|
169
|
+
const fee = estimateFee({ asset })
|
|
170
|
+
const stakeAmount = isMax ? getMaxStakeAmount({ asset, spendable, fee }) : amount
|
|
171
|
+
|
|
172
|
+
if (stakeAmount.lt(minStakingAmount)) {
|
|
173
|
+
return fail('BELOW_MINIMUM', minStakingAmount.sub(stakeAmount), asset.name)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (spendable.lt(stakeAmount)) {
|
|
177
|
+
return fail('INSUFFICIENT_BALANCE', stakeAmount.sub(spendable), asset.name)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const required = stakeAmount.add(fee)
|
|
181
|
+
if (spendable.lt(required)) {
|
|
182
|
+
return fail('INSUFFICIENT_FEE_BALANCE', required.sub(spendable), asset.feeAsset.name)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return pass(spendable.sub(required).lt(estimateFee({ asset })) && 'LOW_FEE_BALANCE')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const canUnstake = ({ asset, accountState, positionIds }) => {
|
|
189
|
+
const stakes = selectStakes({ stakingInfo: accountState.stakingInfo, positionIds })
|
|
190
|
+
if (stakes.length === 0) return fail('NOTHING_TO_UNSTAKE')
|
|
191
|
+
|
|
192
|
+
const { spendable } = getBalances({ asset, accountState })
|
|
193
|
+
const fee = estimateFee({ asset })
|
|
194
|
+
if (spendable.lt(fee)) {
|
|
195
|
+
return fail('INSUFFICIENT_FEE_BALANCE', fee.sub(spendable), asset.feeAsset.name)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return pass()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const canPerform = ({
|
|
202
|
+
asset,
|
|
203
|
+
accountState,
|
|
204
|
+
txLog,
|
|
205
|
+
feeData,
|
|
206
|
+
operation,
|
|
207
|
+
amount,
|
|
208
|
+
isMax,
|
|
209
|
+
positionIds,
|
|
210
|
+
}) => {
|
|
211
|
+
if (!SUPPORTED_OPERATIONS.has(operation)) return fail('OPERATION_UNSUPPORTED')
|
|
212
|
+
if (!accountState.stakingInfo.loaded) return fail('NOT_LOADED')
|
|
213
|
+
if (getPendingOperation(txLog)) return fail('PENDING_TRANSACTION')
|
|
214
|
+
if (operation === 'stake') return canStake({ asset, accountState, feeData, amount, isMax })
|
|
215
|
+
return canUnstake({ asset, accountState, positionIds })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const classifyTx = ({ asset, tx }) => {
|
|
219
|
+
if (asset.name !== asset.baseAsset.name) return
|
|
220
|
+
const staking = tx.data?.staking
|
|
221
|
+
if (!staking) return
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
operation: staking.operation,
|
|
225
|
+
...(staking.amount && { amount: asset.currency.baseUnit(staking.amount) }),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const loadState = async ({ asset, walletAccount }) => {
|
|
230
|
+
const baseAssetName = asset.baseAsset.name
|
|
231
|
+
const [accountState, txLog, feeData, address] = await Promise.all([
|
|
232
|
+
assetClientInterface.getAccountState({ assetName: baseAssetName, walletAccount }),
|
|
233
|
+
assetClientInterface.getTxLog({ assetName: baseAssetName, walletAccount }),
|
|
234
|
+
assetClientInterface.getFeeData({ assetName: asset.feeAsset.name }),
|
|
235
|
+
assetClientInterface.getReceiveAddress({ assetName: baseAssetName, walletAccount }),
|
|
236
|
+
])
|
|
237
|
+
return { accountState, txLog, feeData, address: address.toString() }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const simulateGas = async ({ asset, sender, transaction }) => {
|
|
241
|
+
const transactionBlock = await serverApi.buildTransaction({
|
|
242
|
+
transaction,
|
|
243
|
+
onlyTransactionKind: true,
|
|
244
|
+
})
|
|
245
|
+
const { effects } = await serverApi.devInspectTransaction({ sender, transactionBlock })
|
|
246
|
+
if (effects.status?.status !== 'success') {
|
|
247
|
+
throw new Error(`Sui staking simulation failed: ${effects.status?.error ?? 'unknown'}`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const { currency } = asset.feeAsset
|
|
251
|
+
const fee = getFeeAmount({ gasUsed: effects.gasUsed, feeAsset: asset.feeAsset })
|
|
252
|
+
const computation = currency.baseUnit(effects.gasUsed.computationCost)
|
|
253
|
+
const charged = fee.gt(computation) ? fee : computation
|
|
254
|
+
const budget = Math.ceil(
|
|
255
|
+
Number(charged.toBaseString({ unit: false })) * GAS_BUDGET_SAFETY_FACTOR
|
|
256
|
+
)
|
|
257
|
+
return { fee, gasBudget: currency.baseUnit(Math.max(budget, MIN_GAS_BUDGET)) }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const reject = ({ operation, check }) => {
|
|
261
|
+
throw createRejectionError({ operation, check })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const prepareStake = async ({ asset, accountState, feeData, address, amount, isMax }) => {
|
|
265
|
+
const minStakingAmount = getMinStakingAmount(feeData)
|
|
266
|
+
const validator = stakingSettings.getValidator()
|
|
267
|
+
const { spendable } = getBalances({ asset, accountState })
|
|
268
|
+
const build = (stakeAmount) =>
|
|
269
|
+
buildStakeTransaction({
|
|
270
|
+
sender: address,
|
|
271
|
+
amount: stakeAmount.toBaseString({ unit: false }),
|
|
272
|
+
validator,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const estimate = await simulateGas({
|
|
276
|
+
asset,
|
|
277
|
+
sender: address,
|
|
278
|
+
transaction: build(isMax ? minStakingAmount : amount),
|
|
279
|
+
})
|
|
280
|
+
const stakeAmount = isMax
|
|
281
|
+
? getMaxStakeAmount({ asset, spendable, fee: estimate.gasBudget })
|
|
282
|
+
: amount
|
|
283
|
+
if (stakeAmount.lt(minStakingAmount)) {
|
|
284
|
+
reject({
|
|
285
|
+
operation: 'stake',
|
|
286
|
+
check: fail('BELOW_MINIMUM', minStakingAmount.sub(stakeAmount), asset.name),
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const required = stakeAmount.add(estimate.gasBudget)
|
|
291
|
+
if (spendable.lt(required)) {
|
|
292
|
+
reject({
|
|
293
|
+
operation: 'stake',
|
|
294
|
+
check: fail('INSUFFICIENT_FEE_BALANCE', required.sub(spendable), asset.feeAsset.name),
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
transaction: build(stakeAmount),
|
|
300
|
+
...estimate,
|
|
301
|
+
amount: stakeAmount,
|
|
302
|
+
staking: { operation: 'stake', validator, amount: stakeAmount.toBaseString({ unit: false }) },
|
|
303
|
+
notifyAmount: stakeAmount,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const prepareUnstake = async ({ asset, accountState, address, positionIds }) => {
|
|
308
|
+
const { currency } = asset
|
|
309
|
+
const stakes = selectStakes({ stakingInfo: accountState.stakingInfo, positionIds })
|
|
310
|
+
const stakedSuiIds = stakes.map(({ id }) => id)
|
|
311
|
+
const build = () => buildUnstakeTransaction({ sender: address, stakedSuiIds })
|
|
312
|
+
const estimate = await simulateGas({ asset, sender: address, transaction: build() })
|
|
313
|
+
const { spendable } = getBalances({ asset, accountState })
|
|
314
|
+
if (spendable.lt(estimate.gasBudget)) {
|
|
315
|
+
reject({
|
|
316
|
+
operation: 'unstake',
|
|
317
|
+
check: fail(
|
|
318
|
+
'INSUFFICIENT_FEE_BALANCE',
|
|
319
|
+
estimate.gasBudget.sub(spendable),
|
|
320
|
+
asset.feeAsset.name
|
|
321
|
+
),
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const validator = stakingSettings.getValidator()
|
|
326
|
+
const notifyAmount = sum({
|
|
327
|
+
currency,
|
|
328
|
+
amounts: stakes
|
|
329
|
+
.filter((stake) => stake.validator === validator)
|
|
330
|
+
.map((stake) => currency.baseUnit(stake.principal)),
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
transaction: build(),
|
|
335
|
+
...estimate,
|
|
336
|
+
amount: sum({ currency, amounts: stakes.map((stake) => getStakeValue({ currency, stake })) }),
|
|
337
|
+
staking: { operation: 'unstake', validator, stakedSuiIds },
|
|
338
|
+
notifyAmount,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const prepare = async ({ asset, walletAccount, operation, amount, isMax, positionIds }) => {
|
|
343
|
+
const { accountState, txLog, feeData, address } = await loadState({ asset, walletAccount })
|
|
344
|
+
const check = canPerform({
|
|
345
|
+
asset,
|
|
346
|
+
accountState,
|
|
347
|
+
txLog,
|
|
348
|
+
feeData,
|
|
349
|
+
operation,
|
|
350
|
+
amount,
|
|
351
|
+
isMax,
|
|
352
|
+
positionIds,
|
|
353
|
+
})
|
|
354
|
+
if (!check.ok) reject({ operation, check })
|
|
355
|
+
|
|
356
|
+
const prepared =
|
|
357
|
+
operation === 'stake'
|
|
358
|
+
? await prepareStake({ asset, accountState, feeData, address, amount, isMax })
|
|
359
|
+
: await prepareUnstake({ asset, accountState, address, positionIds })
|
|
360
|
+
|
|
361
|
+
prepared.transaction.setGasBudget(prepared.gasBudget.toBaseString({ unit: false }))
|
|
362
|
+
const txData = await serverApi.buildTransaction({ transaction: prepared.transaction })
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
operation,
|
|
366
|
+
assetName: asset.name,
|
|
367
|
+
walletAccount: walletAccount.toString(),
|
|
368
|
+
txs: [
|
|
369
|
+
{
|
|
370
|
+
txData,
|
|
371
|
+
txMeta: {
|
|
372
|
+
staking: {
|
|
373
|
+
...prepared.staking,
|
|
374
|
+
notifyAmount: prepared.notifyAmount.toBaseString({ unit: false }),
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
submit: 'single',
|
|
380
|
+
amount: prepared.amount,
|
|
381
|
+
fee: prepared.fee,
|
|
382
|
+
feeAssetName: asset.feeAsset.name,
|
|
383
|
+
expiresAt: Date.now() + PLAN_TTL_MS,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const notifyProvider = async ({ asset, walletAccount, unsignedTx, txId }) => {
|
|
388
|
+
const { operation, validator, notifyAmount } = unsignedTx.txMeta.staking
|
|
389
|
+
if (BigInt(notifyAmount) === BigInt(0)) return
|
|
390
|
+
try {
|
|
391
|
+
const address = await assetClientInterface.getReceiveAddress({
|
|
392
|
+
assetName: asset.baseAsset.name,
|
|
393
|
+
walletAccount,
|
|
394
|
+
})
|
|
395
|
+
await notify({
|
|
396
|
+
assetName: asset.name,
|
|
397
|
+
operation,
|
|
398
|
+
delegator: address.toString(),
|
|
399
|
+
validator,
|
|
400
|
+
amount: notifyAmount,
|
|
401
|
+
txId,
|
|
402
|
+
})
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.error('Failed to notify staking service:', error)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const submit = async ({ asset, walletAccount, plan, onProgress }) => {
|
|
409
|
+
assert(plan.assetName === asset.name, 'plan was prepared for another asset')
|
|
410
|
+
assert(
|
|
411
|
+
plan.walletAccount === walletAccount.toString(),
|
|
412
|
+
'plan was prepared for another wallet account'
|
|
413
|
+
)
|
|
414
|
+
if (plan.expiresAt && Date.now() > plan.expiresAt) {
|
|
415
|
+
reject({ operation: plan.operation, check: fail('PLAN_EXPIRED') })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const [unsignedTx] = plan.txs
|
|
419
|
+
onProgress?.({ index: 0, total: 1 })
|
|
420
|
+
let txId
|
|
421
|
+
try {
|
|
422
|
+
;({ txId } = await asset.api.sendTx({ asset, walletAccount, unsignedTx }))
|
|
423
|
+
} catch (error) {
|
|
424
|
+
throw createSubmitError({ error, txIds: [], failedIndex: 0 })
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (notify) void notifyProvider({ asset, walletAccount, unsignedTx, txId })
|
|
428
|
+
|
|
429
|
+
return { txIds: [txId] }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
getCapabilities,
|
|
434
|
+
getLimits,
|
|
435
|
+
getStakingState,
|
|
436
|
+
canPerform,
|
|
437
|
+
classifyTx,
|
|
438
|
+
prepare,
|
|
439
|
+
submit,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const sumBy = ({ currency, stakes, toAmount }) =>
|
|
2
|
+
stakes.reduce((total, stake) => total.add(toAmount(stake)), currency.ZERO)
|
|
3
|
+
|
|
4
|
+
export const getStakeValue = ({ currency, stake }) =>
|
|
5
|
+
currency.baseUnit(stake.principal).add(currency.baseUnit(stake.estimatedReward))
|
|
6
|
+
|
|
7
|
+
export const getStakingBalances = ({ asset, stakingInfo }) => {
|
|
8
|
+
const { currency } = asset
|
|
9
|
+
const byStatus = (status) => stakingInfo.stakes.filter((stake) => stake.status === status)
|
|
10
|
+
const value = (stake) => getStakeValue({ currency, stake })
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
staking: sumBy({ currency, stakes: byStatus('Pending'), toAmount: value }),
|
|
14
|
+
staked: sumBy({ currency, stakes: byStatus('Active'), toAmount: value }),
|
|
15
|
+
unstaking: currency.ZERO,
|
|
16
|
+
unstaked: sumBy({ currency, stakes: byStatus('Unstaked'), toAmount: value }),
|
|
17
|
+
rewards: currency.ZERO,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const fail = (reasonCode, shortfall, shortfallAssetName) => ({
|
|
2
|
+
ok: false,
|
|
3
|
+
reasonCode,
|
|
4
|
+
...(shortfall && { shortfall, shortfallAssetName }),
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
export const pass = (warning) => (warning ? { ok: true, warnings: [warning] } : { ok: true })
|
|
8
|
+
|
|
9
|
+
export const createRejectionError = ({ operation, check }) => {
|
|
10
|
+
const error = new Error(`Cannot ${operation}: ${check.reasonCode}`)
|
|
11
|
+
error.name = 'StakingRejectionError'
|
|
12
|
+
error.reasonCode = check.reasonCode
|
|
13
|
+
if (check.shortfall) {
|
|
14
|
+
error.shortfall = check.shortfall
|
|
15
|
+
error.shortfallAssetName = check.shortfallAssetName
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return error
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const createSubmitError = ({ error, txIds, failedIndex }) => {
|
|
22
|
+
const submitError = new Error(error.message, { cause: error })
|
|
23
|
+
submitError.name = 'StakingSubmitError'
|
|
24
|
+
submitError.txIds = txIds
|
|
25
|
+
submitError.failedIndex = failedIndex
|
|
26
|
+
return submitError
|
|
27
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { STAKED_SUI_TYPE } from './constants.js'
|
|
2
|
+
|
|
3
|
+
const STAKE_STATUSES = new Set(['Pending', 'Active', 'Unstaked'])
|
|
4
|
+
|
|
5
|
+
export const EMPTY_STAKING_INFO = Object.freeze({ loaded: false, stakes: [], epoch: null })
|
|
6
|
+
|
|
7
|
+
const toStake = ({ validatorAddress, stake }) => {
|
|
8
|
+
if (!STAKE_STATUSES.has(stake.status)) {
|
|
9
|
+
throw new Error(`Unknown sui stake status ${stake.status}`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
id: stake.stakedSuiId,
|
|
14
|
+
validator: validatorAddress,
|
|
15
|
+
status: stake.status,
|
|
16
|
+
principal: stake.principal,
|
|
17
|
+
estimatedReward: stake.estimatedReward ?? '0',
|
|
18
|
+
stakeRequestEpoch: Number(stake.stakeRequestEpoch),
|
|
19
|
+
stakeActiveEpoch: Number(stake.stakeActiveEpoch),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const toEpochInfo = (systemState) => ({
|
|
24
|
+
epoch: Number(systemState.epoch),
|
|
25
|
+
startMs: Number(systemState.epochStartTimestampMs),
|
|
26
|
+
durationMs: Number(systemState.epochDurationMs),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const isEpochExpired = ({ epoch, now }) => !epoch || now >= epoch.startMs + epoch.durationMs
|
|
30
|
+
|
|
31
|
+
export const toStakingInfo = ({ delegatedStakes, epoch }) => ({
|
|
32
|
+
loaded: true,
|
|
33
|
+
stakes: delegatedStakes.flatMap(({ validatorAddress, stakes }) =>
|
|
34
|
+
stakes.map((stake) => toStake({ validatorAddress, stake }))
|
|
35
|
+
),
|
|
36
|
+
epoch,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const getEpochStartMs = ({ epoch, targetEpoch }) =>
|
|
40
|
+
epoch.startMs + (targetEpoch - epoch.epoch) * epoch.durationMs
|
|
41
|
+
|
|
42
|
+
const isOwnedStakedSui = ({ change, ownerAddress }) =>
|
|
43
|
+
change.type === 'created' &&
|
|
44
|
+
change.objectType === STAKED_SUI_TYPE &&
|
|
45
|
+
change.owner?.AddressOwner === ownerAddress
|
|
46
|
+
|
|
47
|
+
export const applyStakingTx = ({ stakingInfo, staking, objectChanges, ownerAddress }) => {
|
|
48
|
+
if (!stakingInfo.loaded) return stakingInfo
|
|
49
|
+
|
|
50
|
+
if (staking.operation === 'unstake') {
|
|
51
|
+
const withdrawn = new Set(staking.stakedSuiIds)
|
|
52
|
+
return { ...stakingInfo, stakes: stakingInfo.stakes.filter(({ id }) => !withdrawn.has(id)) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const currentEpoch = stakingInfo.epoch?.epoch
|
|
56
|
+
const created = objectChanges
|
|
57
|
+
.filter((change) => isOwnedStakedSui({ change, ownerAddress }))
|
|
58
|
+
.map((change) => ({
|
|
59
|
+
id: change.objectId,
|
|
60
|
+
validator: staking.validator,
|
|
61
|
+
status: 'Pending',
|
|
62
|
+
principal: staking.amount,
|
|
63
|
+
estimatedReward: '0',
|
|
64
|
+
stakeRequestEpoch: currentEpoch ?? null,
|
|
65
|
+
stakeActiveEpoch: currentEpoch === undefined ? null : currentEpoch + 1,
|
|
66
|
+
}))
|
|
67
|
+
return { ...stakingInfo, stakes: [...stakingInfo.stakes, ...created] }
|
|
68
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from 'minimalistic-assert'
|
|
2
|
+
import ms from 'ms'
|
|
3
|
+
|
|
4
|
+
const HTTP_POST_TIMEOUT = ms('30s')
|
|
5
|
+
|
|
6
|
+
const ACTION_BY_OPERATION = {
|
|
7
|
+
stake: 'start',
|
|
8
|
+
unstake: 'stop',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_ERROR_BODY_LENGTH = 200
|
|
12
|
+
|
|
13
|
+
const readErrorBody = async (response) => {
|
|
14
|
+
const text = await response.text()
|
|
15
|
+
return text.slice(0, MAX_ERROR_BODY_LENGTH)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const postJson = async ({ url, body, timeoutMs }) => {
|
|
19
|
+
const controller = new AbortController()
|
|
20
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(url, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal: controller.signal,
|
|
27
|
+
})
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`${url} returned ${response.status}: ${await readErrorBody(response)}`)
|
|
30
|
+
}
|
|
31
|
+
} finally {
|
|
32
|
+
clearTimeout(timeout)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const createStakingProviderClient = ({ stakingSettings, timeoutMs = HTTP_POST_TIMEOUT }) => {
|
|
37
|
+
assert(stakingSettings, '`stakingSettings` required')
|
|
38
|
+
|
|
39
|
+
const notify = async ({ assetName, operation, delegator, validator, amount, txId }) => {
|
|
40
|
+
const type = ACTION_BY_OPERATION[operation]
|
|
41
|
+
assert(type, `unsupported staking notify operation ${operation}`)
|
|
42
|
+
assert(assetName, '"assetName" is required')
|
|
43
|
+
assert(delegator, '"delegator" is required')
|
|
44
|
+
assert(validator, '"validator" is required')
|
|
45
|
+
assert(amount, '"amount" is required')
|
|
46
|
+
assert(txId, '"txId" is required')
|
|
47
|
+
if (!stakingSettings.isNotifyEnabled()) return
|
|
48
|
+
|
|
49
|
+
await postJson({
|
|
50
|
+
url: `${stakingSettings.getUrl()}/${assetName}/stake`,
|
|
51
|
+
body: { delegator, actions: [{ type, validator, amount, txId }] },
|
|
52
|
+
timeoutMs,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { notify }
|
|
57
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { isValidAddress } from '@exodus/sui-lib'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_STAKING_URL, EVERSTAKE_VALIDATOR } from './constants.js'
|
|
4
|
+
|
|
5
|
+
const assertValidator = (validator) => {
|
|
6
|
+
if (!isValidAddress(validator)) throw new Error(`Invalid sui staking validator ${validator}`)
|
|
7
|
+
return validator
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const assertUrl = (url) => new URL(url).toString().replace(/\/$/, '')
|
|
11
|
+
|
|
12
|
+
export const createStakingSettings = (
|
|
13
|
+
{ validator = EVERSTAKE_VALIDATOR, notify = Object.create(null) } = Object.create(null)
|
|
14
|
+
) => {
|
|
15
|
+
const localNotifyEnabled = notify.enabled === true
|
|
16
|
+
const defaults = {
|
|
17
|
+
validator: assertValidator(validator),
|
|
18
|
+
notifyEnabled: localNotifyEnabled,
|
|
19
|
+
url: assertUrl(notify.url ?? DEFAULT_STAKING_URL),
|
|
20
|
+
}
|
|
21
|
+
let current = { ...defaults }
|
|
22
|
+
|
|
23
|
+
const applyRemoteConfig = (staking) => {
|
|
24
|
+
const remote = staking ?? Object.create(null)
|
|
25
|
+
current = {
|
|
26
|
+
validator: remote.validator ? assertValidator(remote.validator) : defaults.validator,
|
|
27
|
+
notifyEnabled: localNotifyEnabled && remote.notifyEnabled !== false,
|
|
28
|
+
url: remote.url ? assertUrl(remote.url) : defaults.url,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
isNotifyConfigured: localNotifyEnabled,
|
|
34
|
+
getValidator: () => current.validator,
|
|
35
|
+
isNotifyEnabled: () => current.notifyEnabled,
|
|
36
|
+
getUrl: () => current.url,
|
|
37
|
+
applyRemoteConfig,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Transaction } from '../vendors/transactions/index.js'
|
|
2
|
+
import {
|
|
3
|
+
ADD_STAKE_FUNCTION,
|
|
4
|
+
ADD_STAKE_TARGET,
|
|
5
|
+
SUI_SYSTEM_MODULE,
|
|
6
|
+
SUI_SYSTEM_PACKAGE,
|
|
7
|
+
WITHDRAW_STAKE_FUNCTION,
|
|
8
|
+
WITHDRAW_STAKE_TARGET,
|
|
9
|
+
} from './constants.js'
|
|
10
|
+
|
|
11
|
+
const OPERATION_BY_FUNCTION = {
|
|
12
|
+
[ADD_STAKE_FUNCTION]: 'stake',
|
|
13
|
+
[WITHDRAW_STAKE_FUNCTION]: 'unstake',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const buildStakeTransaction = ({ sender, amount, validator }) => {
|
|
17
|
+
const tx = new Transaction()
|
|
18
|
+
const [stakeCoin] = tx.splitCoins(tx.gas, [amount])
|
|
19
|
+
tx.moveCall({
|
|
20
|
+
target: ADD_STAKE_TARGET,
|
|
21
|
+
arguments: [tx.object.system(), stakeCoin, tx.pure.address(validator)],
|
|
22
|
+
})
|
|
23
|
+
tx.setSender(sender)
|
|
24
|
+
return tx
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const buildUnstakeTransaction = ({ sender, stakedSuiIds }) => {
|
|
28
|
+
if (stakedSuiIds.length === 0) throw new Error('stakedSuiIds required to unstake')
|
|
29
|
+
|
|
30
|
+
const tx = new Transaction()
|
|
31
|
+
for (const stakedSuiId of stakedSuiIds) {
|
|
32
|
+
tx.moveCall({
|
|
33
|
+
target: WITHDRAW_STAKE_TARGET,
|
|
34
|
+
arguments: [tx.object.system(), tx.object(stakedSuiId)],
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
tx.setSender(sender)
|
|
39
|
+
return tx
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isSuiSystemCall = (moveCall) =>
|
|
43
|
+
BigInt(moveCall.package) === BigInt(SUI_SYSTEM_PACKAGE) &&
|
|
44
|
+
moveCall.module === SUI_SYSTEM_MODULE &&
|
|
45
|
+
Boolean(OPERATION_BY_FUNCTION[moveCall.function])
|
|
46
|
+
|
|
47
|
+
const getInput = ({ inputs, argument }) =>
|
|
48
|
+
argument && typeof argument === 'object' && 'Input' in argument
|
|
49
|
+
? inputs[argument.Input]
|
|
50
|
+
: undefined
|
|
51
|
+
|
|
52
|
+
const getSplitAmount = ({ inputs, commands, argument }) => {
|
|
53
|
+
const resultIndex = argument?.NestedResult?.[0] ?? argument?.Result
|
|
54
|
+
const amountArgument = commands[resultIndex]?.SplitCoins?.[1]?.[0]
|
|
55
|
+
return getInput({ inputs, argument: amountArgument })?.value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const parseStakingCommands = (programmableTransaction) => {
|
|
59
|
+
if (programmableTransaction?.kind !== 'ProgrammableTransaction') return
|
|
60
|
+
|
|
61
|
+
const { inputs, transactions: commands } = programmableTransaction
|
|
62
|
+
const moveCalls = commands
|
|
63
|
+
.map((command) => command.MoveCall)
|
|
64
|
+
.filter((moveCall) => moveCall && isSuiSystemCall(moveCall))
|
|
65
|
+
if (moveCalls.length === 0) return
|
|
66
|
+
|
|
67
|
+
const operations = new Set(moveCalls.map((moveCall) => OPERATION_BY_FUNCTION[moveCall.function]))
|
|
68
|
+
if (operations.size > 1) return
|
|
69
|
+
|
|
70
|
+
const [operation] = operations
|
|
71
|
+
if (operation === 'stake') {
|
|
72
|
+
if (moveCalls.length > 1) return
|
|
73
|
+
const [, coinArgument, validatorArgument] = moveCalls[0].arguments
|
|
74
|
+
const amount = getSplitAmount({ inputs, commands, argument: coinArgument })
|
|
75
|
+
return {
|
|
76
|
+
operation,
|
|
77
|
+
validator: getInput({ inputs, argument: validatorArgument })?.value,
|
|
78
|
+
...(amount && { amount }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
operation,
|
|
84
|
+
stakedSuiIds: moveCalls.map(
|
|
85
|
+
(moveCall) => getInput({ inputs, argument: moveCall.arguments[1] }).objectId
|
|
86
|
+
),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -6,6 +6,7 @@ import lodash from 'lodash'
|
|
|
6
6
|
import assert from 'minimalistic-assert'
|
|
7
7
|
import ms from 'ms'
|
|
8
8
|
|
|
9
|
+
import { isEpochExpired, toEpochInfo, toStakingInfo } from './staking/staking-info.js'
|
|
9
10
|
import { transformTransactions } from './transform-tx.js'
|
|
10
11
|
|
|
11
12
|
const { isEmpty, unionBy, zipObject } = lodash
|
|
@@ -14,8 +15,20 @@ export const UNCONFIRMED_TX_LIMIT = ms('5m')
|
|
|
14
15
|
|
|
15
16
|
export class SuiNoHistoryMonitor extends BaseMonitor {
|
|
16
17
|
#serverApi
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
#stakingSettings
|
|
19
|
+
#stakingEnabled
|
|
20
|
+
#epoch = null
|
|
21
|
+
|
|
22
|
+
constructor({
|
|
23
|
+
asset,
|
|
24
|
+
serverApi,
|
|
25
|
+
stakingSettings,
|
|
26
|
+
stakingEnabled = false,
|
|
27
|
+
interval = ms('15s'),
|
|
28
|
+
assetClientInterface,
|
|
29
|
+
runner,
|
|
30
|
+
logger,
|
|
31
|
+
}) {
|
|
19
32
|
super({
|
|
20
33
|
asset,
|
|
21
34
|
interval,
|
|
@@ -24,8 +37,11 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
|
|
|
24
37
|
runner,
|
|
25
38
|
})
|
|
26
39
|
assert(serverApi, 'Sui Node API Client is required!')
|
|
40
|
+
assert(stakingSettings, 'stakingSettings is required')
|
|
27
41
|
|
|
28
42
|
this.#serverApi = serverApi
|
|
43
|
+
this.#stakingSettings = stakingSettings
|
|
44
|
+
this.#stakingEnabled = stakingEnabled
|
|
29
45
|
this.assets = Object.create(null)
|
|
30
46
|
}
|
|
31
47
|
|
|
@@ -35,6 +51,8 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
|
|
|
35
51
|
this.#serverApi.setServer({
|
|
36
52
|
rpcUrl: remoteConfig?.rpcUrl || RPC_URL,
|
|
37
53
|
})
|
|
54
|
+
if (!this.#stakingEnabled) return
|
|
55
|
+
this.#stakingSettings.applyRemoteConfig(remoteConfig?.staking)
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
async tick({ refresh, walletAccount }) {
|
|
@@ -51,6 +69,10 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
|
|
|
51
69
|
this.emit('unknown-tokens', unknownAssetIds)
|
|
52
70
|
}
|
|
53
71
|
|
|
72
|
+
const stakingData = this.#stakingEnabled
|
|
73
|
+
? { stakingInfo: await this.#getStakingInfo({ address: ourWalletAddress }) }
|
|
74
|
+
: Object.create(null)
|
|
75
|
+
|
|
54
76
|
let batch = this.aci.createOperationsBatch()
|
|
55
77
|
|
|
56
78
|
batch = this.aci.updateAccountStateBatch({
|
|
@@ -59,6 +81,7 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
|
|
|
59
81
|
newData: {
|
|
60
82
|
utxos,
|
|
61
83
|
tokenUtxos,
|
|
84
|
+
...stakingData,
|
|
62
85
|
},
|
|
63
86
|
batch,
|
|
64
87
|
})
|
|
@@ -87,6 +110,22 @@ export class SuiNoHistoryMonitor extends BaseMonitor {
|
|
|
87
110
|
await this.aci.executeOperationsBatch(batch)
|
|
88
111
|
}
|
|
89
112
|
|
|
113
|
+
#getStakingInfo = async ({ address }) => {
|
|
114
|
+
const [delegatedStakes, epoch] = await Promise.all([
|
|
115
|
+
this.#serverApi.getStakes({ owner: address }),
|
|
116
|
+
this.#getEpoch(),
|
|
117
|
+
])
|
|
118
|
+
return toStakingInfo({ delegatedStakes, epoch })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#getEpoch = async () => {
|
|
122
|
+
if (isEpochExpired({ epoch: this.#epoch, now: SynchronizedTime.now() })) {
|
|
123
|
+
this.#epoch = toEpochInfo(await this.#serverApi.getLatestSuiSystemState())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return this.#epoch
|
|
127
|
+
}
|
|
128
|
+
|
|
90
129
|
#checkPendingTransactions = ({ pendingTransactions, pendingTxsFromNode }) => {
|
|
91
130
|
const txsToUpdate = []
|
|
92
131
|
const txsToRemove = []
|
package/src/transform-tx.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseStakingCommands } from './staking/staking-transactions.js'
|
|
2
|
+
|
|
1
3
|
const parseTransaction = ({ transaction, asset }) => {
|
|
2
4
|
const { data } = transaction
|
|
3
5
|
const {
|
|
@@ -68,6 +70,10 @@ export function transformTransactions({ transactionResults, address, feeAsset, a
|
|
|
68
70
|
const { gasUsed } = effects
|
|
69
71
|
|
|
70
72
|
const { from, to, coinAmount } = parseTransaction({ transaction, asset })
|
|
73
|
+
const staking =
|
|
74
|
+
asset.name === asset.baseAssetName
|
|
75
|
+
? parseStakingCommands(transaction.data.transaction)
|
|
76
|
+
: undefined
|
|
71
77
|
const { confirmations, error } = parseConfirmations({ effects })
|
|
72
78
|
const wasSend = from === address
|
|
73
79
|
const args = Object.create(null)
|
|
@@ -88,7 +94,7 @@ export function transformTransactions({ transactionResults, address, feeAsset, a
|
|
|
88
94
|
selfSend: wasSend && from === to,
|
|
89
95
|
confirmations,
|
|
90
96
|
error,
|
|
91
|
-
data: Object.create(null),
|
|
97
|
+
data: staking ? { staking } : Object.create(null),
|
|
92
98
|
currencies: {
|
|
93
99
|
[asset.name]: asset.currency,
|
|
94
100
|
[feeAsset.name]: feeAsset.currency,
|
package/src/tx-send.js
CHANGED
|
@@ -2,6 +2,8 @@ import { UtxoCollection } from '@exodus/models'
|
|
|
2
2
|
import { coinTypes, objectTypes, toCoinType } from '@exodus/sui-lib'
|
|
3
3
|
import assert from 'minimalistic-assert'
|
|
4
4
|
|
|
5
|
+
import { applyStakingTx } from './staking/staking-info.js'
|
|
6
|
+
import { parseStakingCommands } from './staking/staking-transactions.js'
|
|
5
7
|
import { getFeeAmount } from './transform-tx.js'
|
|
6
8
|
|
|
7
9
|
const deriveNewUtxos = ({ mutatedObject, balanceChange, deletedObjects, utxos, asset }) => {
|
|
@@ -186,6 +188,10 @@ const getStateChanges = async ({ aci, asset, walletAccount, result }) => {
|
|
|
186
188
|
|
|
187
189
|
newAccountState.utxos = newBaseUtxos.utxos
|
|
188
190
|
|
|
191
|
+
const parsedStaking = wasBaseAssetSent
|
|
192
|
+
? parseStakingCommands(result.transaction?.data?.transaction)
|
|
193
|
+
: undefined
|
|
194
|
+
|
|
189
195
|
let amount
|
|
190
196
|
if (wasBaseAssetSent) {
|
|
191
197
|
amount = asset.currency.baseUnit(ownerBalanceChangesByType[coinTypes.Base]).add(fee)
|
|
@@ -203,23 +209,34 @@ const getStateChanges = async ({ aci, asset, walletAccount, result }) => {
|
|
|
203
209
|
amount = asset.currency.baseUnit(ownerBalanceChangesByType[asset.assetId])
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
const
|
|
212
|
+
const staking = parsedStaking && { ...parsedStaking, amount: amount.abs().toBaseString() }
|
|
213
|
+
const isUnstake = staking?.operation === 'unstake'
|
|
214
|
+
const to = staking ? staking.validator : getRecipientAddress({ balanceChanges, ownerAddress })
|
|
207
215
|
|
|
208
216
|
const tx = {
|
|
209
217
|
txId: digest,
|
|
210
218
|
confirmations: 0,
|
|
211
219
|
coinName: assetName,
|
|
212
|
-
coinAmount: amount.abs().negate(),
|
|
220
|
+
coinAmount: isUnstake ? amount.abs() : amount.abs().negate(),
|
|
213
221
|
feeAmount: fee,
|
|
214
222
|
feeCoinName: feeAsset.name,
|
|
215
223
|
selfSend: false,
|
|
216
|
-
to,
|
|
217
|
-
|
|
224
|
+
...(to && { to }),
|
|
225
|
+
...(isUnstake && { from: [ownerAddress] }),
|
|
226
|
+
data: staking ? { staking } : Object.create(null),
|
|
218
227
|
currencies: { [assetName]: assetCurrency, [feeAsset.name]: feeAsset.currency },
|
|
219
228
|
}
|
|
220
229
|
|
|
221
230
|
return {
|
|
222
231
|
accountStateData: {
|
|
232
|
+
...(staking && {
|
|
233
|
+
stakingInfo: applyStakingTx({
|
|
234
|
+
stakingInfo: accountState.stakingInfo,
|
|
235
|
+
staking,
|
|
236
|
+
objectChanges,
|
|
237
|
+
ownerAddress,
|
|
238
|
+
}),
|
|
239
|
+
}),
|
|
223
240
|
utxos: newAccountState.utxos,
|
|
224
241
|
tokenUtxos: {
|
|
225
242
|
...accountState.tokenUtxos,
|
|
@@ -5,7 +5,8 @@ var __typeError = (msg) => {
|
|
|
5
5
|
|
|
6
6
|
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError('Cannot ' + msg)
|
|
7
7
|
var __privateGet = (obj, member, getter) => (
|
|
8
|
-
__accessCheck(obj, member, 'read from private field'),
|
|
8
|
+
__accessCheck(obj, member, 'read from private field'),
|
|
9
|
+
getter ? getter.call(obj) : member.get(obj)
|
|
9
10
|
)
|
|
10
11
|
var __privateAdd = (obj, member, value) =>
|
|
11
12
|
member.has(obj)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* eslint-disable @exodus/
|
|
1
|
+
/* eslint-disable @exodus/hydra/no-param-reassign-prop-only */
|
|
2
2
|
/* eslint-disable no-return-await */
|
|
3
3
|
import { vendorLib } from '@exodus/sui-lib'
|
|
4
4
|
|
|
@@ -116,18 +116,15 @@ async function resolveObjectReferences(transactionData, client) {
|
|
|
116
116
|
),
|
|
117
117
|
]
|
|
118
118
|
const objectChunks = dedupedIds.length > 0 ? chunk(dedupedIds, MAX_OBJECTS_PER_FETCH) : []
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
})
|
|
126
|
-
)
|
|
119
|
+
const resolvedChunks = await Promise.all(
|
|
120
|
+
objectChunks.map((chunk2) =>
|
|
121
|
+
client.multiGetObjects({
|
|
122
|
+
ids: chunk2,
|
|
123
|
+
options: { showOwner: true },
|
|
124
|
+
})
|
|
127
125
|
)
|
|
128
126
|
)
|
|
129
|
-
|
|
130
|
-
.flat()
|
|
127
|
+
const resolved = resolvedChunks.flat()
|
|
131
128
|
const responsesById = new Map(
|
|
132
129
|
dedupedIds.map((id, index) => {
|
|
133
130
|
return [id, resolved[index]]
|
|
@@ -22,7 +22,8 @@ var __typeError = (msg) => {
|
|
|
22
22
|
|
|
23
23
|
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError('Cannot ' + msg)
|
|
24
24
|
var __privateGet = (obj, member, getter) => (
|
|
25
|
-
__accessCheck(obj, member, 'read from private field'),
|
|
25
|
+
__accessCheck(obj, member, 'read from private field'),
|
|
26
|
+
getter ? getter.call(obj) : member.get(obj)
|
|
26
27
|
)
|
|
27
28
|
var __privateAdd = (obj, member, value) =>
|
|
28
29
|
member.has(obj)
|
|
@@ -36,7 +37,8 @@ var __privateSet = (obj, member, value, setter) => (
|
|
|
36
37
|
value
|
|
37
38
|
)
|
|
38
39
|
var __privateMethod = (obj, member, method) => (
|
|
39
|
-
__accessCheck(obj, member, 'access private method'),
|
|
40
|
+
__accessCheck(obj, member, 'access private method'),
|
|
41
|
+
method
|
|
40
42
|
)
|
|
41
43
|
var _serializationPlugins,
|
|
42
44
|
_buildPlugins,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
2
1
|
import { hashSync } from '@exodus/crypto/hash'
|
|
3
2
|
|
|
4
3
|
function hashTypedData(typeTag, data) {
|
|
@@ -7,7 +6,6 @@ function hashTypedData(typeTag, data) {
|
|
|
7
6
|
const dataWithTag = new Uint8Array(typeTagBytes.length + data.length)
|
|
8
7
|
dataWithTag.set(typeTagBytes)
|
|
9
8
|
dataWithTag.set(data, typeTagBytes.length)
|
|
10
|
-
// eslint-disable-next-line @exodus/import/no-deprecated
|
|
11
9
|
return hashSync('blake2b256', dataWithTag)
|
|
12
10
|
}
|
|
13
11
|
|
|
@@ -88,7 +88,7 @@ function normalizeRawArgument(arg, schema, transactionData) {
|
|
|
88
88
|
return
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// eslint-disable-next-line @exodus/
|
|
91
|
+
// eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
|
|
92
92
|
transactionData.inputs[arg.Input] = Inputs.Pure(schema.serialize(input.UnresolvedPure.value))
|
|
93
93
|
}
|
|
94
94
|
|
package/src/vendors/valibot.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable unicorn/no-await-expression-member */
|
|
2
2
|
/* eslint-disable sonarjs/no-nested-template-literals */
|
|
3
|
-
/* eslint-disable @exodus/
|
|
3
|
+
/* eslint-disable @exodus/hydra/no-param-reassign-prop-only */
|
|
4
4
|
// src/storages/globalConfig/globalConfig.ts
|
|
5
5
|
|
|
6
6
|
// @__NO_SIDE_EFFECTS__
|