@ignitionfi/fogo-stake-pool 1.0.2 → 1.1.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.
@@ -38,12 +38,50 @@ export async function getValidatorListAccount(connection: Connection, pubkey: Pu
38
38
  }
39
39
 
40
40
  export interface ValidatorAccount {
41
- type: 'preferred' | 'active' | 'transient' | 'reserve'
41
+ type: 'preferred' | 'active' | 'transient'
42
42
  voteAddress?: PublicKey | undefined
43
43
  stakeAddress: PublicKey
44
44
  lamports: BN
45
45
  }
46
46
 
47
+ export interface PrepareWithdrawResult {
48
+ withdrawAccounts: WithdrawAccount[]
49
+ /** Pool tokens that will be withdrawn via delayed unstake */
50
+ delayedAmount: BN
51
+ /** Pool tokens remaining that need instant unstake */
52
+ remainingAmount: BN
53
+ }
54
+
55
+ /** Pre-fetched data to avoid duplicate RPC calls */
56
+ export interface PrepareWithdrawPrefetchedData {
57
+ /** Raw validator list account data from getAccountInfo */
58
+ validatorListData: Buffer | null
59
+ /** Rent exemption for stake accounts in lamports */
60
+ minBalanceForRentExemption: number
61
+ /** Minimum stake delegation in lamports */
62
+ stakeMinimumDelegation: number
63
+ }
64
+
65
+ export async function prepareWithdrawAccounts(
66
+ connection: Connection,
67
+ stakePool: StakePool,
68
+ stakePoolAddress: PublicKey,
69
+ amount: BN,
70
+ compareFn?: (a: ValidatorAccount, b: ValidatorAccount) => number,
71
+ skipFee?: boolean,
72
+ allowPartial?: boolean,
73
+ prefetchedData?: PrepareWithdrawPrefetchedData,
74
+ ): Promise<WithdrawAccount[]>
75
+ export async function prepareWithdrawAccounts(
76
+ connection: Connection,
77
+ stakePool: StakePool,
78
+ stakePoolAddress: PublicKey,
79
+ amount: BN,
80
+ compareFn: ((a: ValidatorAccount, b: ValidatorAccount) => number) | undefined,
81
+ skipFee: boolean | undefined,
82
+ allowPartial: true,
83
+ prefetchedData?: PrepareWithdrawPrefetchedData,
84
+ ): Promise<PrepareWithdrawResult>
47
85
  export async function prepareWithdrawAccounts(
48
86
  connection: Connection,
49
87
  stakePool: StakePool,
@@ -51,26 +89,65 @@ export async function prepareWithdrawAccounts(
51
89
  amount: BN,
52
90
  compareFn?: (a: ValidatorAccount, b: ValidatorAccount) => number,
53
91
  skipFee?: boolean,
54
- ): Promise<WithdrawAccount[]> {
92
+ allowPartial?: boolean,
93
+ prefetchedData?: PrepareWithdrawPrefetchedData,
94
+ ): Promise<WithdrawAccount[] | PrepareWithdrawResult> {
55
95
  const stakePoolProgramId = getStakePoolProgramId(connection.rpcEndpoint)
56
- const validatorListAcc = await connection.getAccountInfo(stakePool.validatorList)
57
- const validatorList = ValidatorListLayout.decode(validatorListAcc?.data) as ValidatorList
96
+
97
+ // Use prefetched data if available, otherwise fetch from RPC
98
+ let validatorListData: Buffer | null
99
+ let minBalanceForRentExemption: number
100
+ let stakeMinimumDelegation: number
101
+
102
+ if (prefetchedData) {
103
+ validatorListData = prefetchedData.validatorListData
104
+ minBalanceForRentExemption = prefetchedData.minBalanceForRentExemption
105
+ stakeMinimumDelegation = prefetchedData.stakeMinimumDelegation
106
+ } else {
107
+ const [validatorListAcc, rentExemption, stakeMinimumDelegationResponse] = await Promise.all([
108
+ connection.getAccountInfo(stakePool.validatorList),
109
+ connection.getMinimumBalanceForRentExemption(StakeProgram.space),
110
+ connection.getStakeMinimumDelegation(),
111
+ ])
112
+ validatorListData = validatorListAcc?.data ?? null
113
+ minBalanceForRentExemption = rentExemption
114
+ stakeMinimumDelegation = Number(stakeMinimumDelegationResponse.value)
115
+ }
116
+
117
+ if (!validatorListData) {
118
+ throw new Error('No staked funds available for delayed unstake. Use instant unstake instead.')
119
+ }
120
+
121
+ const validatorList = ValidatorListLayout.decode(validatorListData) as ValidatorList
58
122
 
59
123
  if (!validatorList?.validators || validatorList?.validators.length === 0) {
60
- throw new Error('No accounts found')
124
+ throw new Error('No staked funds available for delayed unstake. Use instant unstake instead.')
61
125
  }
62
126
 
63
- const minBalanceForRentExemption = await connection.getMinimumBalanceForRentExemption(
64
- StakeProgram.space,
127
+ // minBalance = rent + max(stake_minimum_delegation, MINIMUM_ACTIVE_STAKE)
128
+ const minimumDelegation = Math.max(stakeMinimumDelegation, MINIMUM_ACTIVE_STAKE)
129
+ const minBalance = new BN(minBalanceForRentExemption + minimumDelegation)
130
+
131
+ // Threshold for has_active_stake check (ceiling division for lamports_per_pool_token)
132
+ const lamportsPerPoolToken = stakePool.totalLamports
133
+ .add(stakePool.poolTokenSupply)
134
+ .sub(new BN(1))
135
+ .div(stakePool.poolTokenSupply)
136
+ const minimumLamportsWithTolerance = minBalance.add(lamportsPerPoolToken)
137
+
138
+ const hasActiveStake = validatorList.validators.some(
139
+ v => v.status === ValidatorStakeInfoStatus.Active
140
+ && v.activeStakeLamports.gt(minimumLamportsWithTolerance),
65
141
  )
66
- const minBalance = new BN(minBalanceForRentExemption + MINIMUM_ACTIVE_STAKE)
142
+ const hasTransientStake = validatorList.validators.some(
143
+ v => v.status === ValidatorStakeInfoStatus.Active
144
+ && v.transientStakeLamports.gt(minimumLamportsWithTolerance),
145
+ )
146
+
147
+ // ValidatorRemoval mode: no validator above threshold
148
+ const isValidatorRemovalMode = !hasActiveStake && !hasTransientStake
67
149
 
68
- // First, collect all stake account addresses we need to check
69
- const accountsToFetch: Array<{
70
- type: 'preferred' | 'active' | 'transient'
71
- voteAddress: PublicKey
72
- stakeAddress: PublicKey
73
- }> = []
150
+ let accounts: ValidatorAccount[] = []
74
151
 
75
152
  for (const validator of validatorList.validators) {
76
153
  if (validator.status !== ValidatorStakeInfoStatus.Active) {
@@ -83,63 +160,39 @@ export async function prepareWithdrawAccounts(
83
160
  stakePoolAddress,
84
161
  )
85
162
 
86
- const isPreferred = stakePool?.preferredWithdrawValidatorVoteAddress?.equals(
87
- validator.voteAccountAddress,
88
- )
163
+ // ValidatorRemoval: full balance available; Normal: leave minBalance
164
+ const availableActiveLamports = isValidatorRemovalMode
165
+ ? validator.activeStakeLamports
166
+ : validator.activeStakeLamports.sub(minBalance)
89
167
 
90
- // Add active stake account if validator list indicates it has stake
91
- if (validator.activeStakeLamports.gt(new BN(0))) {
92
- accountsToFetch.push({
168
+ if (availableActiveLamports.gt(new BN(0))) {
169
+ const isPreferred = stakePool?.preferredWithdrawValidatorVoteAddress?.equals(
170
+ validator.voteAccountAddress,
171
+ )
172
+ accounts.push({
93
173
  type: isPreferred ? 'preferred' : 'active',
94
174
  voteAddress: validator.voteAccountAddress,
95
175
  stakeAddress: stakeAccountAddress,
176
+ lamports: availableActiveLamports,
96
177
  })
97
178
  }
98
179
 
99
- // Add transient stake account if validator list indicates it has stake
100
- if (validator.transientStakeLamports.gt(new BN(0))) {
180
+ const availableTransientLamports = isValidatorRemovalMode
181
+ ? validator.transientStakeLamports
182
+ : validator.transientStakeLamports.sub(minBalance)
183
+
184
+ if (availableTransientLamports.gt(new BN(0))) {
101
185
  const transientStakeAccountAddress = await findTransientStakeProgramAddress(
102
186
  stakePoolProgramId,
103
187
  validator.voteAccountAddress,
104
188
  stakePoolAddress,
105
189
  validator.transientSeedSuffixStart,
106
190
  )
107
- accountsToFetch.push({
191
+ accounts.push({
108
192
  type: 'transient',
109
193
  voteAddress: validator.voteAccountAddress,
110
194
  stakeAddress: transientStakeAccountAddress,
111
- })
112
- }
113
- }
114
-
115
- // Fetch all stake accounts + reserve in one batch call
116
- const addressesToFetch = [
117
- ...accountsToFetch.map(a => a.stakeAddress),
118
- stakePool.reserveStake,
119
- ]
120
- const accountInfos = await connection.getMultipleAccountsInfo(addressesToFetch)
121
-
122
- // Build accounts list using actual on-chain balances
123
- let accounts: ValidatorAccount[] = []
124
-
125
- for (let i = 0; i < accountsToFetch.length; i++) {
126
- const { type, voteAddress, stakeAddress } = accountsToFetch[i]
127
- const accountInfo = accountInfos[i]
128
-
129
- if (!accountInfo) {
130
- continue
131
- }
132
-
133
- // Use actual on-chain balance instead of validator list value
134
- const actualLamports = new BN(accountInfo.lamports)
135
- const availableLamports = actualLamports.sub(minBalance)
136
-
137
- if (availableLamports.gt(new BN(0))) {
138
- accounts.push({
139
- type,
140
- voteAddress,
141
- stakeAddress,
142
- lamports: availableLamports,
195
+ lamports: availableTransientLamports,
143
196
  })
144
197
  }
145
198
  }
@@ -147,17 +200,6 @@ export async function prepareWithdrawAccounts(
147
200
  // Sort from highest to lowest balance
148
201
  accounts = accounts.sort(compareFn || ((a, b) => b.lamports.sub(a.lamports).toNumber()))
149
202
 
150
- // Add reserve stake using actual balance (last item in batch fetch)
151
- const reserveAccountInfo = accountInfos[accountInfos.length - 1]
152
- const reserveStakeBalance = new BN((reserveAccountInfo?.lamports ?? 0) - minBalanceForRentExemption)
153
- if (reserveStakeBalance.gt(new BN(0))) {
154
- accounts.push({
155
- type: 'reserve',
156
- stakeAddress: stakePool.reserveStake,
157
- lamports: reserveStakeBalance,
158
- })
159
- }
160
-
161
203
  // Prepare the list of accounts to withdraw from
162
204
  const withdrawFrom: WithdrawAccount[] = []
163
205
  let remainingAmount = new BN(amount)
@@ -168,14 +210,10 @@ export async function prepareWithdrawAccounts(
168
210
  denominator: fee.denominator,
169
211
  }
170
212
 
171
- for (const type of ['preferred', 'active', 'transient', 'reserve']) {
213
+ for (const type of ['preferred', 'active', 'transient']) {
172
214
  const filteredAccounts = accounts.filter(a => a.type === type)
173
215
 
174
216
  for (const { stakeAddress, voteAddress, lamports } of filteredAccounts) {
175
- if (lamports.lte(minBalance) && type === 'transient') {
176
- continue
177
- }
178
-
179
217
  let availableForWithdrawal = calcPoolTokensForDeposit(stakePool, lamports)
180
218
 
181
219
  if (!skipFee && !inverseFee.numerator.isZero()) {
@@ -184,12 +222,17 @@ export async function prepareWithdrawAccounts(
184
222
  .div(inverseFee.numerator)
185
223
  }
186
224
 
225
+ // In ValidatorRemoval mode, must withdraw full validator balance (no partial)
226
+ // Skip if remaining amount is less than full validator balance
227
+ if (isValidatorRemovalMode && remainingAmount.lt(availableForWithdrawal)) {
228
+ continue
229
+ }
230
+
187
231
  const poolAmount = BN.min(availableForWithdrawal, remainingAmount)
188
232
  if (poolAmount.lte(new BN(0))) {
189
233
  continue
190
234
  }
191
235
 
192
- // Those accounts will be withdrawn completely with `claim` instruction
193
236
  withdrawFrom.push({ stakeAddress, voteAddress, poolAmount })
194
237
  remainingAmount = remainingAmount.sub(poolAmount)
195
238
 
@@ -205,13 +248,28 @@ export async function prepareWithdrawAccounts(
205
248
 
206
249
  // Not enough stake to withdraw the specified amount
207
250
  if (remainingAmount.gt(new BN(0))) {
251
+ if (allowPartial) {
252
+ const delayedAmount = amount.sub(remainingAmount)
253
+ return {
254
+ withdrawAccounts: withdrawFrom,
255
+ delayedAmount,
256
+ remainingAmount,
257
+ }
258
+ }
259
+ const availableAmount = amount.sub(remainingAmount)
208
260
  throw new Error(
209
- `No stake accounts found in this pool with enough balance to withdraw ${lamportsToSol(
210
- amount,
211
- )} pool tokens.`,
261
+ `Not enough staked funds for delayed unstake. Requested ${lamportsToSol(amount)} iFOGO, but only ${lamportsToSol(availableAmount)} available. Use instant unstake for the remaining amount.`,
212
262
  )
213
263
  }
214
264
 
265
+ if (allowPartial) {
266
+ return {
267
+ withdrawAccounts: withdrawFrom,
268
+ delayedAmount: amount,
269
+ remainingAmount: new BN(0),
270
+ }
271
+ }
272
+
215
273
  return withdrawFrom
216
274
  }
217
275