From 1b3a980bb56ac8b6e0eb48aa6aae3db36524d6d8 Mon Sep 17 00:00:00 2001 From: Jon Cinque Date: Wed, 20 Sep 2023 21:31:20 +0200 Subject: [PATCH] stake-pool: Prefund split account during redelegate (#5285) * stake-pool: Prefund split account during redelegate * stake-pool-js: Add reserve account to redelegate * Only pre-fund the rent-exempt reserve if needed * Add error type for depleting the reserve during rebalance * Do not deplete the reserve while funding rent-exemption * Move error check for overdraw --- stake-pool/js/src/index.ts | 1 + stake-pool/js/src/instructions.ts | 3 + stake-pool/program/src/error.rs | 5 + stake-pool/program/src/instruction.rs | 44 ++++---- stake-pool/program/src/processor.rs | 35 +++++-- stake-pool/program/tests/helpers/mod.rs | 1 + stake-pool/program/tests/redelegate.rs | 133 +++++++++++++++++++++--- 7 files changed, 178 insertions(+), 44 deletions(-) diff --git a/stake-pool/js/src/index.ts b/stake-pool/js/src/index.ts index a06dd6d4335..4e932033aba 100644 --- a/stake-pool/js/src/index.ts +++ b/stake-pool/js/src/index.ts @@ -1092,6 +1092,7 @@ export async function redelegate(props: RedelegateProps) { stakePool: stakePool.pubkey, staker: stakePool.account.data.staker, validatorList: stakePool.account.data.validatorList, + reserveStake: stakePool.account.data.reserveStake, stakePoolWithdrawAuthority, ephemeralStake, ephemeralStakeSeed, diff --git a/stake-pool/js/src/instructions.ts b/stake-pool/js/src/instructions.ts index 60cd9b64e0e..2a8fc9afeeb 100644 --- a/stake-pool/js/src/instructions.ts +++ b/stake-pool/js/src/instructions.ts @@ -325,6 +325,7 @@ export type RedelegateParams = { staker: PublicKey; stakePoolWithdrawAuthority: PublicKey; validatorList: PublicKey; + reserveStake: PublicKey; sourceValidatorStake: PublicKey; sourceTransientStake: PublicKey; ephemeralStake: PublicKey; @@ -841,6 +842,7 @@ export class StakePoolInstruction { staker, stakePoolWithdrawAuthority, validatorList, + reserveStake, sourceValidatorStake, sourceTransientStake, ephemeralStake, @@ -858,6 +860,7 @@ export class StakePoolInstruction { { pubkey: staker, isSigner: true, isWritable: false }, { pubkey: stakePoolWithdrawAuthority, isSigner: false, isWritable: false }, { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, { pubkey: sourceValidatorStake, isSigner: false, isWritable: true }, { pubkey: sourceTransientStake, isSigner: false, isWritable: true }, { pubkey: ephemeralStake, isSigner: false, isWritable: true }, diff --git a/stake-pool/program/src/error.rs b/stake-pool/program/src/error.rs index b3ed8e094b2..9433a2304be 100644 --- a/stake-pool/program/src/error.rs +++ b/stake-pool/program/src/error.rs @@ -149,6 +149,11 @@ pub enum StakePoolError { /// Provided mint does not have 9 decimals to match SOL #[error("IncorrectMintDecimals")] IncorrectMintDecimals, + /// Pool reserve does not have enough lamports to fund rent-exempt reserve in split + /// destination. Deposit more SOL in reserve, or pre-fund split destination with + /// the rent-exempt reserve for a stake account. + #[error("ReserveDepleted")] + ReserveDepleted, } impl From for ProgramError { fn from(e: StakePoolError) -> Self { diff --git a/stake-pool/program/src/instruction.rs b/stake-pool/program/src/instruction.rs index fcda90825c7..6a0f6ad0816 100644 --- a/stake-pool/program/src/instruction.rs +++ b/stake-pool/program/src/instruction.rs @@ -487,34 +487,36 @@ pub enum StakePoolInstruction { /// The instruction only succeeds if the source transient stake account and /// ephemeral stake account do not exist. /// - /// The amount of lamports to move must be at least twice rent-exemption - /// plus the minimum delegation amount. Rent-exemption is required for the - /// source transient stake account, and rent-exemption plus minimum delegation + /// The amount of lamports to move must be at least rent-exemption plus the + /// minimum delegation amount. Rent-exemption plus minimum delegation /// is required for the destination ephemeral stake account. /// + /// The rent-exemption for the source transient account comes from the stake + /// pool reserve, if needed. + /// /// The amount that arrives at the destination validator in the end is - /// `redelegate_lamports - 2 * rent_exemption` if the destination transient - /// account does *not* exist, and `redelegate_lamports - rent_exemption` if - /// the destination transient account already exists. One `rent_exemption` - /// is deactivated with the source transient account during redelegation, - /// and another `rent_exemption` is deactivated when creating the destination - /// transient stake account. + /// `redelegate_lamports - rent_exemption` if the destination transient + /// account does *not* exist, and `redelegate_lamports` if the destination + /// transient account already exists. The `rent_exemption` is not activated + /// when creating the destination transient stake account, but if it already + /// exists, then the full amount is delegated. /// /// 0. `[]` Stake pool /// 1. `[s]` Stake pool staker /// 2. `[]` Stake pool withdraw authority /// 3. `[w]` Validator list - /// 4. `[w]` Source canonical stake account to split from - /// 5. `[w]` Source transient stake account to receive split and be redelegated - /// 6. `[w]` Uninitialized ephemeral stake account to receive redelegation - /// 7. `[w]` Destination transient stake account to receive ephemeral stake by merge - /// 8. `[]` Destination stake account to receive transient stake after activation - /// 9. `[]` Destination validator vote account - /// 10. `[]` Clock sysvar - /// 11. `[]` Stake History sysvar - /// 12. `[]` Stake Config sysvar - /// 13. `[]` System program - /// 14. `[]` Stake program + /// 4. `[w]` Reserve stake account, to withdraw rent exempt reserve + /// 5. `[w]` Source canonical stake account to split from + /// 6. `[w]` Source transient stake account to receive split and be redelegated + /// 7. `[w]` Uninitialized ephemeral stake account to receive redelegation + /// 8. `[w]` Destination transient stake account to receive ephemeral stake by merge + /// 9. `[]` Destination stake account to receive transient stake after activation + /// 10. `[]` Destination validator vote account + /// 11. `[]` Clock sysvar + /// 12. `[]` Stake History sysvar + /// 13. `[]` Stake Config sysvar + /// 14. `[]` System program + /// 15. `[]` Stake program Redelegate { /// Amount of lamports to redelegate #[allow(dead_code)] // but it's not @@ -920,6 +922,7 @@ pub fn redelegate( staker: &Pubkey, stake_pool_withdraw_authority: &Pubkey, validator_list: &Pubkey, + reserve_stake: &Pubkey, source_validator_stake: &Pubkey, source_transient_stake: &Pubkey, ephemeral_stake: &Pubkey, @@ -936,6 +939,7 @@ pub fn redelegate( AccountMeta::new_readonly(*staker, true), AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), AccountMeta::new(*validator_list, false), + AccountMeta::new(*reserve_stake, false), AccountMeta::new(*source_validator_stake, false), AccountMeta::new(*source_transient_stake, false), AccountMeta::new(*ephemeral_stake, false), diff --git a/stake-pool/program/src/processor.rs b/stake-pool/program/src/processor.rs index ae6c416b8c0..2024d674126 100644 --- a/stake-pool/program/src/processor.rs +++ b/stake-pool/program/src/processor.rs @@ -1793,6 +1793,7 @@ impl Processor { let staker_info = next_account_info(account_info_iter)?; let withdraw_authority_info = next_account_info(account_info_iter)?; let validator_list_info = next_account_info(account_info_iter)?; + let reserve_stake_info = next_account_info(account_info_iter)?; let source_validator_stake_account_info = next_account_info(account_info_iter)?; let source_transient_stake_account_info = next_account_info(account_info_iter)?; let ephemeral_stake_account_info = next_account_info(account_info_iter)?; @@ -1829,6 +1830,7 @@ impl Processor { stake_pool.check_validator_list(validator_list_info)?; check_account_owner(validator_list_info, program_id)?; + stake_pool.check_reserve_stake(reserve_stake_info)?; let mut validator_list_data = validator_list_info.data.borrow_mut(); let (header, mut validator_list) = @@ -1844,11 +1846,11 @@ impl Processor { let current_minimum_delegation = minimum_delegation(stake_minimum_delegation); // check that we're redelegating enough - let destination_transient_lamports = { + { // redelegation requires that the source account maintains rent exemption and that // the destination account has rent-exemption and minimum delegation let minimum_redelegation_lamports = - current_minimum_delegation.saturating_add(stake_rent.saturating_mul(2)); + current_minimum_delegation.saturating_add(stake_rent); if lamports < minimum_redelegation_lamports { msg!( "Need more than {} lamports for redelegated stake and transient stake to meet minimum delegation requirement, {} provided", @@ -1877,10 +1879,7 @@ impl Processor { ); return Err(ProgramError::InsufficientFunds); } - lamports - .checked_sub(stake_rent) - .ok_or(StakePoolError::CalculationFailure)? - }; + } // check source account state let (_, stake) = get_stake_state(source_validator_stake_account_info)?; @@ -1945,6 +1944,25 @@ impl Processor { stake_space, )?; + // if needed, pre-fund the rent-exempt reserve from the reserve stake + let required_lamports_for_rent_exemption = + stake_rent.saturating_sub(source_transient_stake_account_info.lamports()); + if required_lamports_for_rent_exemption > 0 { + if required_lamports_for_rent_exemption >= reserve_stake_info.lamports() { + return Err(StakePoolError::ReserveDepleted.into()); + } + Self::stake_withdraw( + stake_pool_info.key, + reserve_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + source_transient_stake_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + required_lamports_for_rent_exemption, + )?; + } Self::stake_split( stake_pool_info.key, source_validator_stake_account_info.clone(), @@ -2021,7 +2039,7 @@ impl Processor { u64::from(validator_stake_info.transient_stake_lamports) > 0; validator_stake_info.transient_stake_lamports = u64::from(validator_stake_info.transient_stake_lamports) - .checked_add(destination_transient_lamports) + .checked_add(lamports) .ok_or(StakePoolError::CalculationFailure)? .into(); @@ -2088,7 +2106,7 @@ impl Processor { withdraw_authority_info.clone(), AUTHORITY_WITHDRAW, stake_pool.stake_withdraw_bump_seed, - destination_transient_lamports, + lamports, destination_transient_stake_account_info.clone(), )?; validator_stake_info.transient_seed_suffix = @@ -4019,6 +4037,7 @@ impl PrintProgramError for StakePoolError { StakePoolError::UnsupportedFeeAccountExtension => msg!("Error: fee account has an unsupported extension"), StakePoolError::ExceededSlippage => msg!("Error: instruction exceeds desired slippage limit"), StakePoolError::IncorrectMintDecimals => msg!("Error: Provided mint does not have 9 decimals to match SOL"), + StakePoolError::ReserveDepleted => msg!("Error: Pool reserve does not have enough lamports to fund rent-exempt reserve in split destination. Deposit more SOL in reserve, or pre-fund split destination with the rent-exempt reserve for a stake account."), } } } diff --git a/stake-pool/program/tests/helpers/mod.rs b/stake-pool/program/tests/helpers/mod.rs index b5cf20ac9b1..0682e30ae54 100644 --- a/stake-pool/program/tests/helpers/mod.rs +++ b/stake-pool/program/tests/helpers/mod.rs @@ -1890,6 +1890,7 @@ impl StakePoolAccounts { &self.staker.pubkey(), &self.withdraw_authority, &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), source_validator_stake, source_transient_stake, ephemeral_stake, diff --git a/stake-pool/program/tests/redelegate.rs b/stake-pool/program/tests/redelegate.rs index 5645baf8dde..707b7067baf 100644 --- a/stake-pool/program/tests/redelegate.rs +++ b/stake-pool/program/tests/redelegate.rs @@ -48,7 +48,7 @@ async fn setup( &mut context.banks_client, &context.payer, &context.last_blockhash, - MINIMUM_RESERVE_LAMPORTS + current_minimum_delegation + stake_rent, + MINIMUM_RESERVE_LAMPORTS + current_minimum_delegation + stake_rent * 2, ) .await .unwrap(); @@ -71,7 +71,7 @@ async fn setup( ) .await; - let minimum_redelegate_lamports = current_minimum_delegation + stake_rent * 2; + let minimum_redelegate_lamports = current_minimum_delegation + stake_rent; simple_deposit_stake( &mut context.banks_client, &context.payer, @@ -212,7 +212,22 @@ async fn success() { assert_eq!(source_transient_stake_account.lamports, stake_rent); let transient_delegation = transient_stake_state.delegation().unwrap(); assert_ne!(transient_delegation.deactivation_epoch, Epoch::MAX); - assert_eq!(transient_delegation.stake, redelegate_lamports - stake_rent); + assert_eq!(transient_delegation.stake, redelegate_lamports); + + // Check reserve stake + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &last_blockhash, + ) + .await; + let reserve_lamports = MINIMUM_RESERVE_LAMPORTS + current_minimum_delegation + stake_rent * 2; + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + assert_eq!(reserve_stake_account.lamports, reserve_lamports); // Check ephemeral account doesn't exist let maybe_account = context @@ -232,15 +247,12 @@ async fn success() { deserialize::(&destination_transient_stake_account.data).unwrap(); assert_eq!( destination_transient_stake_account.lamports, - redelegate_lamports - stake_rent + redelegate_lamports ); let transient_delegation = transient_stake_state.delegation().unwrap(); assert_eq!(transient_delegation.deactivation_epoch, Epoch::MAX); assert_ne!(transient_delegation.activation_epoch, Epoch::MAX); - assert_eq!( - transient_delegation.stake, - redelegate_lamports - stake_rent * 2 - ); + assert_eq!(transient_delegation.stake, redelegate_lamports - stake_rent); // Check validator list let validator_list = stake_pool_accounts @@ -324,7 +336,7 @@ async fn success() { assert_eq!(u64::from(destination_item.transient_stake_lamports), 0); assert_eq!( u64::from(destination_item.active_stake_lamports), - pre_destination_validator_stake_account.lamports + redelegate_lamports - stake_rent * 2 + pre_destination_validator_stake_account.lamports + redelegate_lamports - stake_rent ); let post_destination_validator_stake_account = get_account( &mut context.banks_client, @@ -333,7 +345,18 @@ async fn success() { .await; assert_eq!( post_destination_validator_stake_account.lamports, - pre_destination_validator_stake_account.lamports + redelegate_lamports - stake_rent * 2 + pre_destination_validator_stake_account.lamports + redelegate_lamports - stake_rent + ); + + // Check reserve stake, which has claimed back all rent-exempt reserves + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + assert_eq!( + reserve_stake_account.lamports, + reserve_lamports + stake_rent * 2 ); } @@ -477,10 +500,9 @@ async fn success_with_increasing_stake() { .await; let transient_stake_state = deserialize::(&destination_transient_stake_account.data).unwrap(); - // stake rent cancels out assert_eq!( destination_transient_stake_account.lamports, - redelegate_lamports + current_minimum_delegation + redelegate_lamports + current_minimum_delegation + stake_rent ); let transient_delegation = transient_stake_state.delegation().unwrap(); @@ -488,9 +510,17 @@ async fn success_with_increasing_stake() { assert_ne!(transient_delegation.activation_epoch, Epoch::MAX); assert_eq!( transient_delegation.stake, - redelegate_lamports + current_minimum_delegation - stake_rent + redelegate_lamports + current_minimum_delegation ); + // Check reserve stake + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + assert_eq!(reserve_stake_account.lamports, stake_rent); + // Check validator list let validator_list = stake_pool_accounts .get_validator_list(&mut context.banks_client) @@ -540,12 +570,11 @@ async fn success_with_increasing_stake() { .find(&destination_validator_stake.vote.pubkey()) .unwrap(); assert_eq!(u64::from(destination_item.transient_stake_lamports), 0); - // redelegate is smart enough to activate *everything*, so there's only one rent-exemption + // redelegate is smart enough to activate *everything*, so there's no rent-exemption // worth of inactive stake! assert_eq!( u64::from(destination_item.active_stake_lamports), pre_validator_stake_account.lamports + redelegate_lamports + current_minimum_delegation - - stake_rent ); let post_validator_stake_account = get_account( &mut context.banks_client, @@ -555,8 +584,16 @@ async fn success_with_increasing_stake() { assert_eq!( post_validator_stake_account.lamports, pre_validator_stake_account.lamports + redelegate_lamports + current_minimum_delegation - - stake_rent ); + + // Check reserve has claimed back rent-exempt reserve from both transient + // accounts + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + assert_eq!(reserve_stake_account.lamports, stake_rent * 3); } #[tokio::test] @@ -690,6 +727,7 @@ async fn fail_with_wrong_withdraw_authority() { &stake_pool_accounts.staker.pubkey(), &wrong_withdraw_authority, &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), &source_validator_stake.stake_account, &source_validator_stake.transient_stake_account, &ephemeral_stake, @@ -750,6 +788,7 @@ async fn fail_with_wrong_validator_list() { &stake_pool_accounts.staker.pubkey(), &stake_pool_accounts.withdraw_authority, &wrong_validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), &source_validator_stake.stake_account, &source_validator_stake.transient_stake_account, &ephemeral_stake, @@ -782,6 +821,67 @@ async fn fail_with_wrong_validator_list() { ); } +#[tokio::test] +async fn fail_with_wrong_reserve() { + let ( + mut context, + last_blockhash, + stake_pool_accounts, + source_validator_stake, + destination_validator_stake, + redelegate_lamports, + _, + ) = setup(true).await; + + let ephemeral_stake_seed = 2; + let ephemeral_stake = find_ephemeral_stake_program_address( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + ephemeral_stake_seed, + ) + .0; + + let wrong_reserve = Keypair::new(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::redelegate( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &wrong_reserve.pubkey(), + &source_validator_stake.stake_account, + &source_validator_stake.transient_stake_account, + &ephemeral_stake, + &destination_validator_stake.transient_stake_account, + &destination_validator_stake.stake_account, + &destination_validator_stake.vote.pubkey(), + redelegate_lamports, + source_validator_stake.transient_stake_seed, + ephemeral_stake_seed, + destination_validator_stake.transient_stake_seed, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.staker], + last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::InvalidProgramAddress as u32) + ) + ); +} + #[tokio::test] async fn fail_with_wrong_staker() { let ( @@ -810,6 +910,7 @@ async fn fail_with_wrong_staker() { &wrong_staker.pubkey(), &stake_pool_accounts.withdraw_authority, &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), &source_validator_stake.stake_account, &source_validator_stake.transient_stake_account, &ephemeral_stake,