Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

stake-pool: Wait at least two epoch boundaries to set fee #3979

Merged
merged 1 commit into from
Jan 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/src/stake-pool/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,18 +169,19 @@ Signature: 5yPXfVj5cbKBfZiEVi2UR5bXzVDuc2c3ruBwSjkAqpvxPHigwGHiS1mXQVE4qwok5moMW
```

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

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

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

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

The possible options for the fee type are `epoch`, `sol-withdrawal`,
`stake-withdrawal`, `sol-deposit`, and `stake-deposit`.
Expand Down
9 changes: 4 additions & 5 deletions stake-pool/cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,8 @@ impl From<(Pubkey, StakePool, ValidatorList, Pubkey)> for CliStakePool {
last_update_epoch: stake_pool.last_update_epoch,
lockup: CliStakePoolLockup::from(stake_pool.lockup),
epoch_fee: CliStakePoolFee::from(stake_pool.epoch_fee),
next_epoch_fee: stake_pool.next_epoch_fee.map(CliStakePoolFee::from),
next_epoch_fee: Option::<Fee>::from(stake_pool.next_epoch_fee)
.map(CliStakePoolFee::from),
preferred_deposit_validator_vote_address: stake_pool
.preferred_deposit_validator_vote_address
.map(|x| x.to_string()),
Expand All @@ -484,17 +485,15 @@ impl From<(Pubkey, StakePool, ValidatorList, Pubkey)> for CliStakePool {
.map(|x| x.to_string()),
stake_deposit_fee: CliStakePoolFee::from(stake_pool.stake_deposit_fee),
stake_withdrawal_fee: CliStakePoolFee::from(stake_pool.stake_withdrawal_fee),
next_stake_withdrawal_fee: stake_pool
.next_stake_withdrawal_fee
next_stake_withdrawal_fee: Option::<Fee>::from(stake_pool.next_stake_withdrawal_fee)
.map(CliStakePoolFee::from),
stake_referral_fee: stake_pool.stake_referral_fee,
sol_deposit_authority: stake_pool.sol_deposit_authority.map(|x| x.to_string()),
sol_deposit_fee: CliStakePoolFee::from(stake_pool.sol_deposit_fee),
sol_referral_fee: stake_pool.sol_referral_fee,
sol_withdraw_authority: stake_pool.sol_withdraw_authority.map(|x| x.to_string()),
sol_withdrawal_fee: CliStakePoolFee::from(stake_pool.sol_withdrawal_fee),
next_sol_withdrawal_fee: stake_pool
.next_sol_withdrawal_fee
next_sol_withdrawal_fee: Option::<Fee>::from(stake_pool.next_sol_withdrawal_fee)
.map(CliStakePoolFee::from),
last_epoch_pool_token_supply: stake_pool.last_epoch_pool_token_supply,
last_epoch_total_lamports: stake_pool.last_epoch_total_lamports,
Expand Down
32 changes: 18 additions & 14 deletions stake-pool/program/src/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ use {
instruction::{FundingType, PreferredValidatorType, StakePoolInstruction},
minimum_delegation, minimum_reserve_lamports, minimum_stake_lamports,
state::{
is_extension_supported_for_mint, AccountType, Fee, FeeType, StakePool, StakeStatus,
StakeWithdrawSource, ValidatorList, ValidatorListHeader, ValidatorStakeInfo,
is_extension_supported_for_mint, AccountType, Fee, FeeType, FutureEpoch, StakePool,
StakeStatus, StakeWithdrawSource, ValidatorList, ValidatorListHeader,
ValidatorStakeInfo,
},
AUTHORITY_DEPOSIT, AUTHORITY_WITHDRAW, EPHEMERAL_STAKE_SEED_PREFIX,
TRANSIENT_STAKE_SEED_PREFIX,
Expand Down Expand Up @@ -905,19 +906,19 @@ impl Processor {
stake_pool.last_update_epoch = Clock::get()?.epoch;
stake_pool.lockup = stake::state::Lockup::default();
stake_pool.epoch_fee = epoch_fee;
stake_pool.next_epoch_fee = None;
stake_pool.next_epoch_fee = FutureEpoch::None;
stake_pool.preferred_deposit_validator_vote_address = None;
stake_pool.preferred_withdraw_validator_vote_address = None;
stake_pool.stake_deposit_fee = deposit_fee;
stake_pool.stake_withdrawal_fee = withdrawal_fee;
stake_pool.next_stake_withdrawal_fee = None;
stake_pool.next_stake_withdrawal_fee = FutureEpoch::None;
stake_pool.stake_referral_fee = referral_fee;
stake_pool.sol_deposit_authority = sol_deposit_authority;
stake_pool.sol_deposit_fee = deposit_fee;
stake_pool.sol_referral_fee = referral_fee;
stake_pool.sol_withdraw_authority = None;
stake_pool.sol_withdrawal_fee = withdrawal_fee;
stake_pool.next_sol_withdrawal_fee = None;
stake_pool.next_sol_withdrawal_fee = FutureEpoch::None;
stake_pool.last_epoch_pool_token_supply = 0;
stake_pool.last_epoch_total_lamports = 0;

Expand Down Expand Up @@ -2532,18 +2533,21 @@ impl Processor {
}

if stake_pool.last_update_epoch < clock.epoch {
if let Some(fee) = stake_pool.next_epoch_fee {
stake_pool.epoch_fee = fee;
stake_pool.next_epoch_fee = None;
if let Some(fee) = stake_pool.next_epoch_fee.get() {
stake_pool.epoch_fee = *fee;
}
if let Some(fee) = stake_pool.next_stake_withdrawal_fee {
stake_pool.stake_withdrawal_fee = fee;
stake_pool.next_stake_withdrawal_fee = None;
stake_pool.next_epoch_fee.update_epoch();

if let Some(fee) = stake_pool.next_stake_withdrawal_fee.get() {
stake_pool.stake_withdrawal_fee = *fee;
}
if let Some(fee) = stake_pool.next_sol_withdrawal_fee {
stake_pool.sol_withdrawal_fee = fee;
stake_pool.next_sol_withdrawal_fee = None;
stake_pool.next_stake_withdrawal_fee.update_epoch();

if let Some(fee) = stake_pool.next_sol_withdrawal_fee.get() {
stake_pool.sol_withdrawal_fee = *fee;
}
stake_pool.next_sol_withdrawal_fee.update_epoch();

stake_pool.last_update_epoch = clock.epoch;
stake_pool.last_epoch_total_lamports = previous_lamports;
stake_pool.last_epoch_pool_token_supply = previous_pool_token_supply;
Expand Down
68 changes: 62 additions & 6 deletions stake-pool/program/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ pub struct StakePool {
pub epoch_fee: Fee,

/// Fee for next epoch
pub next_epoch_fee: Option<Fee>,
pub next_epoch_fee: FutureEpoch<Fee>,

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

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

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

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

/// Last epoch's total pool tokens, used only for APR estimation
pub last_epoch_pool_token_supply: u64,
Expand Down Expand Up @@ -483,14 +483,14 @@ impl StakePool {
match fee {
FeeType::SolReferral(new_fee) => self.sol_referral_fee = *new_fee,
FeeType::StakeReferral(new_fee) => self.stake_referral_fee = *new_fee,
FeeType::Epoch(new_fee) => self.next_epoch_fee = Some(*new_fee),
FeeType::Epoch(new_fee) => self.next_epoch_fee = FutureEpoch::new(*new_fee),
FeeType::StakeWithdrawal(new_fee) => {
new_fee.check_withdrawal(&self.stake_withdrawal_fee)?;
self.next_stake_withdrawal_fee = Some(*new_fee)
self.next_stake_withdrawal_fee = FutureEpoch::new(*new_fee)
}
FeeType::SolWithdrawal(new_fee) => {
new_fee.check_withdrawal(&self.sol_withdrawal_fee)?;
self.next_sol_withdrawal_fee = Some(*new_fee)
self.next_sol_withdrawal_fee = FutureEpoch::new(*new_fee)
}
FeeType::SolDeposit(new_fee) => self.sol_deposit_fee = *new_fee,
FeeType::StakeDeposit(new_fee) => self.stake_deposit_fee = *new_fee,
Expand Down Expand Up @@ -793,6 +793,62 @@ impl ValidatorListHeader {
}
}

/// Wrapper type that "counts down" epochs, which is Borsh-compatible with the
/// native `Option`
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, BorshSerialize, BorshDeserialize, BorshSchema)]
pub enum FutureEpoch<T> {
/// Nothing is set
None,
/// Value is ready after the next epoch boundary
One(T),
/// Value is ready after two epoch boundaries
Two(T),
}
impl<T> Default for FutureEpoch<T> {
fn default() -> Self {
Self::None
}
}
impl<T> FutureEpoch<T> {
/// Create a new value to be unlocked in a two epochs
pub fn new(value: T) -> Self {
Self::Two(value)
}
}
impl<T: Clone> FutureEpoch<T> {
/// Update the epoch, to be done after `get`ting the underlying value
pub fn update_epoch(&mut self) {
match self {
Self::None => {}
Self::One(_) => {
// The value has waited its last epoch
*self = Self::None;
}
// The value still has to wait one more epoch after this
Self::Two(v) => {
*self = Self::One(v.clone());
}
}
}

/// Get the value if it's ready, which is only at `One` epoch remaining
pub fn get(&self) -> Option<&T> {
match self {
Self::None | Self::Two(_) => None,
Self::One(v) => Some(v),
}
}
Comment on lines +821 to +841
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is cute

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I certainly had fun writing it 😄

}
impl<T> From<FutureEpoch<T>> for Option<T> {
fn from(v: FutureEpoch<T>) -> Option<T> {
match v {
FutureEpoch::None => None,
FutureEpoch::One(inner) | FutureEpoch::Two(inner) => Some(inner),
}
}
}

/// Fee rate as a ratio, minted on `UpdateStakePoolBalance` as a proportion of
/// the rewards
/// If either the numerator or the denominator is 0, the fee is considered to be 0
Expand Down
8 changes: 4 additions & 4 deletions stake-pool/program/tests/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use {
find_stake_program_address, find_transient_stake_program_address,
find_withdraw_authority_program_address, id, instruction, minimum_delegation,
processor::Processor,
state::{self, FeeType, StakePool, ValidatorList},
state::{self, FeeType, FutureEpoch, StakePool, ValidatorList},
MINIMUM_RESERVE_LAMPORTS,
},
spl_token_2022::{
Expand Down Expand Up @@ -1776,19 +1776,19 @@ impl StakePoolAccounts {
last_update_epoch: 0,
lockup: stake::state::Lockup::default(),
epoch_fee: self.epoch_fee,
next_epoch_fee: None,
next_epoch_fee: FutureEpoch::None,
preferred_deposit_validator_vote_address: None,
preferred_withdraw_validator_vote_address: None,
stake_deposit_fee: state::Fee::default(),
sol_deposit_fee: state::Fee::default(),
stake_withdrawal_fee: state::Fee::default(),
next_stake_withdrawal_fee: None,
next_stake_withdrawal_fee: FutureEpoch::None,
stake_referral_fee: 0,
sol_referral_fee: 0,
sol_deposit_authority: None,
sol_withdraw_authority: None,
sol_withdrawal_fee: state::Fee::default(),
next_sol_withdrawal_fee: None,
next_sol_withdrawal_fee: FutureEpoch::None,
last_epoch_pool_token_supply: 0,
last_epoch_total_lamports: 0,
};
Expand Down
33 changes: 30 additions & 3 deletions stake-pool/program/tests/set_epoch_fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use {
},
spl_stake_pool::{
error, id, instruction,
state::{Fee, FeeType, StakePool},
state::{Fee, FeeType, FutureEpoch, StakePool},
MINIMUM_RESERVE_LAMPORTS,
},
};
Expand Down Expand Up @@ -76,7 +76,7 @@ async fn success() {
let stake_pool = try_from_slice_unchecked::<StakePool>(stake_pool.data.as_slice()).unwrap();

assert_eq!(stake_pool.epoch_fee, old_fee);
assert_eq!(stake_pool.next_epoch_fee, Some(new_fee));
assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::Two(new_fee));

let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot;
let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch;
Expand All @@ -94,14 +94,41 @@ async fn success() {
)
.await;

let stake_pool = get_account(
&mut context.banks_client,
&stake_pool_accounts.stake_pool.pubkey(),
)
.await;
let stake_pool = try_from_slice_unchecked::<StakePool>(stake_pool.data.as_slice()).unwrap();
assert_eq!(stake_pool.epoch_fee, old_fee);
assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::One(new_fee));

let last_blockhash = context
.banks_client
.get_new_latest_blockhash(&context.last_blockhash)
.await
.unwrap();
context
.warp_to_slot(first_normal_slot + 2 * slots_per_epoch)
.unwrap();
stake_pool_accounts
.update_all(
&mut context.banks_client,
&context.payer,
&last_blockhash,
&[],
false,
)
.await;

let stake_pool = get_account(
&mut context.banks_client,
&stake_pool_accounts.stake_pool.pubkey(),
)
.await;
let stake_pool = try_from_slice_unchecked::<StakePool>(stake_pool.data.as_slice()).unwrap();
assert_eq!(stake_pool.epoch_fee, new_fee);
assert_eq!(stake_pool.next_epoch_fee, None);
assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::None);
}

#[tokio::test]
Expand Down
Loading