Skip to content
This repository was archived by the owner on Mar 11, 2025. It is now read-only.

Commit dd95bb6

Browse files
committed
stake-pool: Wait at least two epoch boundaries to set fee
1 parent 6ab15b3 commit dd95bb6

File tree

7 files changed

+409
-56
lines changed

7 files changed

+409
-56
lines changed

docs/src/stake-pool/cli.md

+6-5
Original file line numberDiff line numberDiff line change
@@ -169,18 +169,19 @@ Signature: 5yPXfVj5cbKBfZiEVi2UR5bXzVDuc2c3ruBwSjkAqpvxPHigwGHiS1mXQVE4qwok5moMW
169169
```
170170

171171
In order to protect stake pool depositors from malicious managers, the program
172-
applies the new fee for the following epoch.
172+
applies the new fee after crossing two epoch boundaries, giving a minimum wait
173+
time of one full epoch.
173174

174175
For example, if the fee is 1% at epoch 100, and the manager sets it to 10%, the
175-
manager will still gain 1% for the rewards earned during epoch 100. Starting
176-
with epoch 101, the manager will earn 10%.
176+
manager will still gain 1% for the rewards earned during epochs 100 and 101. Starting
177+
with epoch 102, the manager will earn 10%.
177178

178179
Additionally, to prevent a malicious manager from immediately setting the withdrawal
179180
fee to a very high amount, making it practically impossible for users to withdraw,
180181
the stake pool program currently enforces a limit of 1.5x increase per epoch.
181182

182-
For example, if the current withdrawal fee is 2.5%, the maximum that can be set
183-
for the next epoch is 3.75%.
183+
For example, if the current withdrawal fee is 2.5%, the maximum settable fee is
184+
3.75%, and will take effect after two epoch boundaries.
184185

185186
The possible options for the fee type are `epoch`, `sol-withdrawal`,
186187
`stake-withdrawal`, `sol-deposit`, and `stake-deposit`.

stake-pool/cli/src/output.rs

+4-5
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,8 @@ impl From<(Pubkey, StakePool, ValidatorList, Pubkey)> for CliStakePool {
475475
last_update_epoch: stake_pool.last_update_epoch,
476476
lockup: CliStakePoolLockup::from(stake_pool.lockup),
477477
epoch_fee: CliStakePoolFee::from(stake_pool.epoch_fee),
478-
next_epoch_fee: stake_pool.next_epoch_fee.map(CliStakePoolFee::from),
478+
next_epoch_fee: Option::<Fee>::from(stake_pool.next_epoch_fee)
479+
.map(CliStakePoolFee::from),
479480
preferred_deposit_validator_vote_address: stake_pool
480481
.preferred_deposit_validator_vote_address
481482
.map(|x| x.to_string()),
@@ -484,17 +485,15 @@ impl From<(Pubkey, StakePool, ValidatorList, Pubkey)> for CliStakePool {
484485
.map(|x| x.to_string()),
485486
stake_deposit_fee: CliStakePoolFee::from(stake_pool.stake_deposit_fee),
486487
stake_withdrawal_fee: CliStakePoolFee::from(stake_pool.stake_withdrawal_fee),
487-
next_stake_withdrawal_fee: stake_pool
488-
.next_stake_withdrawal_fee
488+
next_stake_withdrawal_fee: Option::<Fee>::from(stake_pool.next_stake_withdrawal_fee)
489489
.map(CliStakePoolFee::from),
490490
stake_referral_fee: stake_pool.stake_referral_fee,
491491
sol_deposit_authority: stake_pool.sol_deposit_authority.map(|x| x.to_string()),
492492
sol_deposit_fee: CliStakePoolFee::from(stake_pool.sol_deposit_fee),
493493
sol_referral_fee: stake_pool.sol_referral_fee,
494494
sol_withdraw_authority: stake_pool.sol_withdraw_authority.map(|x| x.to_string()),
495495
sol_withdrawal_fee: CliStakePoolFee::from(stake_pool.sol_withdrawal_fee),
496-
next_sol_withdrawal_fee: stake_pool
497-
.next_sol_withdrawal_fee
496+
next_sol_withdrawal_fee: Option::<Fee>::from(stake_pool.next_sol_withdrawal_fee)
498497
.map(CliStakePoolFee::from),
499498
last_epoch_pool_token_supply: stake_pool.last_epoch_pool_token_supply,
500499
last_epoch_total_lamports: stake_pool.last_epoch_total_lamports,

stake-pool/program/src/processor.rs

+18-14
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use {
77
instruction::{FundingType, PreferredValidatorType, StakePoolInstruction},
88
minimum_delegation, minimum_reserve_lamports, minimum_stake_lamports,
99
state::{
10-
is_extension_supported_for_mint, AccountType, Fee, FeeType, StakePool, StakeStatus,
11-
StakeWithdrawSource, ValidatorList, ValidatorListHeader, ValidatorStakeInfo,
10+
is_extension_supported_for_mint, AccountType, Fee, FeeType, FutureEpoch, StakePool,
11+
StakeStatus, StakeWithdrawSource, ValidatorList, ValidatorListHeader,
12+
ValidatorStakeInfo,
1213
},
1314
AUTHORITY_DEPOSIT, AUTHORITY_WITHDRAW, EPHEMERAL_STAKE_SEED_PREFIX,
1415
TRANSIENT_STAKE_SEED_PREFIX,
@@ -905,19 +906,19 @@ impl Processor {
905906
stake_pool.last_update_epoch = Clock::get()?.epoch;
906907
stake_pool.lockup = stake::state::Lockup::default();
907908
stake_pool.epoch_fee = epoch_fee;
908-
stake_pool.next_epoch_fee = None;
909+
stake_pool.next_epoch_fee = FutureEpoch::None;
909910
stake_pool.preferred_deposit_validator_vote_address = None;
910911
stake_pool.preferred_withdraw_validator_vote_address = None;
911912
stake_pool.stake_deposit_fee = deposit_fee;
912913
stake_pool.stake_withdrawal_fee = withdrawal_fee;
913-
stake_pool.next_stake_withdrawal_fee = None;
914+
stake_pool.next_stake_withdrawal_fee = FutureEpoch::None;
914915
stake_pool.stake_referral_fee = referral_fee;
915916
stake_pool.sol_deposit_authority = sol_deposit_authority;
916917
stake_pool.sol_deposit_fee = deposit_fee;
917918
stake_pool.sol_referral_fee = referral_fee;
918919
stake_pool.sol_withdraw_authority = None;
919920
stake_pool.sol_withdrawal_fee = withdrawal_fee;
920-
stake_pool.next_sol_withdrawal_fee = None;
921+
stake_pool.next_sol_withdrawal_fee = FutureEpoch::None;
921922
stake_pool.last_epoch_pool_token_supply = 0;
922923
stake_pool.last_epoch_total_lamports = 0;
923924

@@ -2532,18 +2533,21 @@ impl Processor {
25322533
}
25332534

25342535
if stake_pool.last_update_epoch < clock.epoch {
2535-
if let Some(fee) = stake_pool.next_epoch_fee {
2536-
stake_pool.epoch_fee = fee;
2537-
stake_pool.next_epoch_fee = None;
2536+
if let Some(fee) = stake_pool.next_epoch_fee.get() {
2537+
stake_pool.epoch_fee = *fee;
25382538
}
2539-
if let Some(fee) = stake_pool.next_stake_withdrawal_fee {
2540-
stake_pool.stake_withdrawal_fee = fee;
2541-
stake_pool.next_stake_withdrawal_fee = None;
2539+
stake_pool.next_epoch_fee.update_epoch();
2540+
2541+
if let Some(fee) = stake_pool.next_stake_withdrawal_fee.get() {
2542+
stake_pool.stake_withdrawal_fee = *fee;
25422543
}
2543-
if let Some(fee) = stake_pool.next_sol_withdrawal_fee {
2544-
stake_pool.sol_withdrawal_fee = fee;
2545-
stake_pool.next_sol_withdrawal_fee = None;
2544+
stake_pool.next_stake_withdrawal_fee.update_epoch();
2545+
2546+
if let Some(fee) = stake_pool.next_sol_withdrawal_fee.get() {
2547+
stake_pool.sol_withdrawal_fee = *fee;
25462548
}
2549+
stake_pool.next_sol_withdrawal_fee.update_epoch();
2550+
25472551
stake_pool.last_update_epoch = clock.epoch;
25482552
stake_pool.last_epoch_total_lamports = previous_lamports;
25492553
stake_pool.last_epoch_pool_token_supply = previous_pool_token_supply;

stake-pool/program/src/state.rs

+62-6
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ pub struct StakePool {
104104
pub epoch_fee: Fee,
105105

106106
/// Fee for next epoch
107-
pub next_epoch_fee: Option<Fee>,
107+
pub next_epoch_fee: FutureEpoch<Fee>,
108108

109109
/// Preferred deposit validator vote account pubkey
110110
pub preferred_deposit_validator_vote_address: Option<Pubkey>,
@@ -119,7 +119,7 @@ pub struct StakePool {
119119
pub stake_withdrawal_fee: Fee,
120120

121121
/// Future stake withdrawal fee, to be set for the following epoch
122-
pub next_stake_withdrawal_fee: Option<Fee>,
122+
pub next_stake_withdrawal_fee: FutureEpoch<Fee>,
123123

124124
/// Fees paid out to referrers on referred stake deposits.
125125
/// Expressed as a percentage (0 - 100) of deposit fees.
@@ -148,7 +148,7 @@ pub struct StakePool {
148148
pub sol_withdrawal_fee: Fee,
149149

150150
/// Future SOL withdrawal fee, to be set for the following epoch
151-
pub next_sol_withdrawal_fee: Option<Fee>,
151+
pub next_sol_withdrawal_fee: FutureEpoch<Fee>,
152152

153153
/// Last epoch's total pool tokens, used only for APR estimation
154154
pub last_epoch_pool_token_supply: u64,
@@ -483,14 +483,14 @@ impl StakePool {
483483
match fee {
484484
FeeType::SolReferral(new_fee) => self.sol_referral_fee = *new_fee,
485485
FeeType::StakeReferral(new_fee) => self.stake_referral_fee = *new_fee,
486-
FeeType::Epoch(new_fee) => self.next_epoch_fee = Some(*new_fee),
486+
FeeType::Epoch(new_fee) => self.next_epoch_fee = FutureEpoch::new(*new_fee),
487487
FeeType::StakeWithdrawal(new_fee) => {
488488
new_fee.check_withdrawal(&self.stake_withdrawal_fee)?;
489-
self.next_stake_withdrawal_fee = Some(*new_fee)
489+
self.next_stake_withdrawal_fee = FutureEpoch::new(*new_fee)
490490
}
491491
FeeType::SolWithdrawal(new_fee) => {
492492
new_fee.check_withdrawal(&self.sol_withdrawal_fee)?;
493-
self.next_sol_withdrawal_fee = Some(*new_fee)
493+
self.next_sol_withdrawal_fee = FutureEpoch::new(*new_fee)
494494
}
495495
FeeType::SolDeposit(new_fee) => self.sol_deposit_fee = *new_fee,
496496
FeeType::StakeDeposit(new_fee) => self.stake_deposit_fee = *new_fee,
@@ -793,6 +793,62 @@ impl ValidatorListHeader {
793793
}
794794
}
795795

796+
/// Wrapper type that "counts down" epochs, which is Borsh-compatible with the
797+
/// native `Option`
798+
#[repr(C)]
799+
#[derive(Clone, Copy, Debug, PartialEq, BorshSerialize, BorshDeserialize, BorshSchema)]
800+
pub enum FutureEpoch<T> {
801+
/// Nothing is set
802+
None,
803+
/// Value is ready after the next epoch boundary
804+
One(T),
805+
/// Value is ready after two epoch boundaries
806+
Two(T),
807+
}
808+
impl<T> Default for FutureEpoch<T> {
809+
fn default() -> Self {
810+
Self::None
811+
}
812+
}
813+
impl<T> FutureEpoch<T> {
814+
/// Create a new value to be unlocked in a two epochs
815+
pub fn new(value: T) -> Self {
816+
Self::Two(value)
817+
}
818+
}
819+
impl<T: Clone> FutureEpoch<T> {
820+
/// Update the epoch, to be done after `get`ting the underlying value
821+
pub fn update_epoch(&mut self) {
822+
match self {
823+
Self::None => {}
824+
Self::One(_) => {
825+
// The value has waited its last epoch
826+
*self = Self::None;
827+
}
828+
// The value still has to wait one more epoch after this
829+
Self::Two(v) => {
830+
*self = Self::One(v.clone());
831+
}
832+
}
833+
}
834+
835+
/// Get the value if it's ready, which is only at `One` epoch remaining
836+
pub fn get(&self) -> Option<&T> {
837+
match self {
838+
Self::None | Self::Two(_) => None,
839+
Self::One(v) => Some(v),
840+
}
841+
}
842+
}
843+
impl<T> From<FutureEpoch<T>> for Option<T> {
844+
fn from(v: FutureEpoch<T>) -> Option<T> {
845+
match v {
846+
FutureEpoch::None => None,
847+
FutureEpoch::One(inner) | FutureEpoch::Two(inner) => Some(inner)
848+
}
849+
}
850+
}
851+
796852
/// Fee rate as a ratio, minted on `UpdateStakePoolBalance` as a proportion of
797853
/// the rewards
798854
/// If either the numerator or the denominator is 0, the fee is considered to be 0

stake-pool/program/tests/helpers/mod.rs

+4-4
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use {
3030
find_stake_program_address, find_transient_stake_program_address,
3131
find_withdraw_authority_program_address, id, instruction, minimum_delegation,
3232
processor::Processor,
33-
state::{self, FeeType, StakePool, ValidatorList},
33+
state::{self, FeeType, FutureEpoch, StakePool, ValidatorList},
3434
MINIMUM_RESERVE_LAMPORTS,
3535
},
3636
spl_token_2022::{
@@ -1776,19 +1776,19 @@ impl StakePoolAccounts {
17761776
last_update_epoch: 0,
17771777
lockup: stake::state::Lockup::default(),
17781778
epoch_fee: self.epoch_fee,
1779-
next_epoch_fee: None,
1779+
next_epoch_fee: FutureEpoch::None,
17801780
preferred_deposit_validator_vote_address: None,
17811781
preferred_withdraw_validator_vote_address: None,
17821782
stake_deposit_fee: state::Fee::default(),
17831783
sol_deposit_fee: state::Fee::default(),
17841784
stake_withdrawal_fee: state::Fee::default(),
1785-
next_stake_withdrawal_fee: None,
1785+
next_stake_withdrawal_fee: FutureEpoch::None,
17861786
stake_referral_fee: 0,
17871787
sol_referral_fee: 0,
17881788
sol_deposit_authority: None,
17891789
sol_withdraw_authority: None,
17901790
sol_withdrawal_fee: state::Fee::default(),
1791-
next_sol_withdrawal_fee: None,
1791+
next_sol_withdrawal_fee: FutureEpoch::None,
17921792
last_epoch_pool_token_supply: 0,
17931793
last_epoch_total_lamports: 0,
17941794
};

stake-pool/program/tests/set_epoch_fee.rs

+30-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use {
1414
},
1515
spl_stake_pool::{
1616
error, id, instruction,
17-
state::{Fee, FeeType, StakePool},
17+
state::{Fee, FeeType, FutureEpoch, StakePool},
1818
MINIMUM_RESERVE_LAMPORTS,
1919
},
2020
};
@@ -76,7 +76,7 @@ async fn success() {
7676
let stake_pool = try_from_slice_unchecked::<StakePool>(stake_pool.data.as_slice()).unwrap();
7777

7878
assert_eq!(stake_pool.epoch_fee, old_fee);
79-
assert_eq!(stake_pool.next_epoch_fee, Some(new_fee));
79+
assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::Two(new_fee));
8080

8181
let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot;
8282
let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch;
@@ -94,14 +94,41 @@ async fn success() {
9494
)
9595
.await;
9696

97+
let stake_pool = get_account(
98+
&mut context.banks_client,
99+
&stake_pool_accounts.stake_pool.pubkey(),
100+
)
101+
.await;
102+
let stake_pool = try_from_slice_unchecked::<StakePool>(stake_pool.data.as_slice()).unwrap();
103+
assert_eq!(stake_pool.epoch_fee, old_fee);
104+
assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::One(new_fee));
105+
106+
let last_blockhash = context
107+
.banks_client
108+
.get_new_latest_blockhash(&context.last_blockhash)
109+
.await
110+
.unwrap();
111+
context
112+
.warp_to_slot(first_normal_slot + 2 * slots_per_epoch)
113+
.unwrap();
114+
stake_pool_accounts
115+
.update_all(
116+
&mut context.banks_client,
117+
&context.payer,
118+
&last_blockhash,
119+
&[],
120+
false,
121+
)
122+
.await;
123+
97124
let stake_pool = get_account(
98125
&mut context.banks_client,
99126
&stake_pool_accounts.stake_pool.pubkey(),
100127
)
101128
.await;
102129
let stake_pool = try_from_slice_unchecked::<StakePool>(stake_pool.data.as_slice()).unwrap();
103130
assert_eq!(stake_pool.epoch_fee, new_fee);
104-
assert_eq!(stake_pool.next_epoch_fee, None);
131+
assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::None);
105132
}
106133

107134
#[tokio::test]

0 commit comments

Comments
 (0)