From bca41edf204e322df03c6d529da32ae0ab256d23 Mon Sep 17 00:00:00 2001 From: Tyera Date: Wed, 20 Sep 2023 00:00:51 -0600 Subject: [PATCH] Make active stake consistent in split (#33295) * Add feature gate * Add helper fn * Require split destination to be rent-exempt if it is active * Update cli to prefund split accounts * cli: require rent param with sign-only * Update tokens to prefund split accounts * Update split tests with sysvar accounts * Fix test_split_to_account_with_rent_exempt_reserve * Fix test_staked_split_destination_minimum_balance * Fix test_split_more_than_staked * Fix test_split_minimum_stake_delegation and remove misleading StakeState::Initialized case * Fix test_split_from_larger_sized_account * Add test for pre-/post-activation behavior splitting some or all of stake account * Assert active stake * Fix runtime test * Ignore stake-pool downstream * Review comments * Feature gate sysvar reads --- .github/workflows/downstream-project-spl.yml | 2 +- cli/src/cli.rs | 4 + cli/src/stake.rs | 78 +- cli/tests/stake.rs | 19 +- programs/stake/src/stake_instruction.rs | 715 +++++++++++++++++-- programs/stake/src/stake_state.rs | 52 +- runtime/tests/stake.rs | 12 +- sdk/src/feature_set.rs | 5 + tokens/src/arg_parser.rs | 1 + tokens/src/args.rs | 1 + tokens/src/commands.rs | 30 +- tokens/src/lib.rs | 1 + tokens/src/main.rs | 3 +- tokens/src/stake.rs | 15 + 14 files changed, 813 insertions(+), 125 deletions(-) create mode 100644 tokens/src/stake.rs diff --git a/.github/workflows/downstream-project-spl.yml b/.github/workflows/downstream-project-spl.yml index f0ecfb20ac..09c457c038 100644 --- a/.github/workflows/downstream-project-spl.yml +++ b/.github/workflows/downstream-project-spl.yml @@ -128,7 +128,7 @@ jobs: - [governance/addin-mock/program, governance/program] - [memo/program] - [name-service/program] - - [stake-pool/program] + # - [stake-pool/program] - [single-pool/program] steps: diff --git a/cli/src/cli.rs b/cli/src/cli.rs index e6960c3fa3..17a35f7da0 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -238,6 +238,7 @@ pub enum CliCommand { lamports: u64, fee_payer: SignerIndex, compute_unit_price: Option, + rent_exempt_reserve: Option, }, MergeStake { stake_account_pubkey: Pubkey, @@ -1226,6 +1227,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { lamports, fee_payer, compute_unit_price, + rent_exempt_reserve, } => process_split_stake( &rpc_client, config, @@ -1242,6 +1244,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { *lamports, *fee_payer, compute_unit_price.as_ref(), + rent_exempt_reserve.as_ref(), ), CliCommand::MergeStake { stake_account_pubkey, @@ -2243,6 +2246,7 @@ mod tests { lamports: 30, fee_payer: 0, compute_unit_price: None, + rent_exempt_reserve: None, }; config.signers = vec![&keypair, &split_stake_account]; let result = process_command(&config); diff --git a/cli/src/stake.rs b/cli/src/stake.rs index 79fe33a098..0410139712 100644 --- a/cli/src/stake.rs +++ b/cli/src/stake.rs @@ -55,7 +55,7 @@ use { tools::{acceptable_reference_epoch_credits, eligible_for_deactivate_delinquent}, }, stake_history::{Epoch, StakeHistory}, - system_instruction::SystemError, + system_instruction::{self, SystemError}, sysvar::{clock, stake_history}, transaction::Transaction, }, @@ -121,6 +121,13 @@ pub struct StakeAuthorizationIndexed { pub new_authority_signer: Option, } +struct SignOnlySplitNeedsRent {} +impl ArgsConfig for SignOnlySplitNeedsRent { + fn sign_only_arg<'a, 'b>(&self, arg: Arg<'a, 'b>) -> Arg<'a, 'b> { + arg.requires("rent_exempt_reserve_sol") + } +} + pub trait StakeSubCommands { fn stake_subcommands(self) -> Self; } @@ -493,11 +500,21 @@ impl StakeSubCommands for App<'_, '_> { will be at a derived address of SPLIT_STAKE_ACCOUNT") ) .arg(stake_authority_arg()) - .offline_args() + .offline_args_config(&SignOnlySplitNeedsRent{}) .nonce_args(false) .arg(fee_payer_arg()) .arg(memo_arg()) .arg(compute_unit_price_arg()) + .arg( + Arg::with_name("rent_exempt_reserve_sol") + .long("rent-exempt-reserve-sol") + .value_name("AMOUNT") + .takes_value(true) + .validator(is_amount) + .requires("sign_only") + .help("Offline signing only: the rent-exempt amount to move into the new \ + stake account, in SOL") + ) ) .subcommand( SubCommand::with_name("merge-stake") @@ -1027,6 +1044,7 @@ pub fn parse_split_stake( let signer_info = default_signer.generate_unique_signers(bulk_signers, matches, wallet_manager)?; let compute_unit_price = value_of(matches, COMPUTE_UNIT_PRICE_ARG.name); + let rent_exempt_reserve = lamports_of_sol(matches, "rent_exempt_reserve_sol"); Ok(CliCommandInfo { command: CliCommand::SplitStake { @@ -1043,6 +1061,7 @@ pub fn parse_split_stake( lamports, fee_payer: signer_info.index_of(fee_payer_pubkey).unwrap(), compute_unit_price, + rent_exempt_reserve, }, signers: signer_info.signers, }) @@ -1852,6 +1871,7 @@ pub fn process_split_stake( lamports: u64, fee_payer: SignerIndex, compute_unit_price: Option<&u64>, + rent_exempt_reserve: Option<&u64>, ) -> ProcessResult { let split_stake_account = config.signers[split_stake_account]; let fee_payer = config.signers[fee_payer]; @@ -1885,7 +1905,7 @@ pub fn process_split_stake( split_stake_account.pubkey() }; - if !sign_only { + let rent_exempt_reserve = if !sign_only { if let Ok(stake_account) = rpc_client.get_account(&split_stake_account_address) { let err_msg = if stake_account.owner == stake::program::id() { format!("Stake account {split_stake_account_address} already exists") @@ -1906,30 +1926,44 @@ pub fn process_split_stake( )) .into()); } - } + minimum_balance + } else { + rent_exempt_reserve + .cloned() + .expect("rent_exempt_reserve_sol is required with sign_only") + }; let recent_blockhash = blockhash_query.get_blockhash(rpc_client, config.commitment)?; - let ixs = if let Some(seed) = split_stake_account_seed { - stake_instruction::split_with_seed( - stake_account_pubkey, - &stake_authority.pubkey(), - lamports, - &split_stake_account_address, - &split_stake_account.pubkey(), - seed, + let mut ixs = vec![system_instruction::transfer( + &fee_payer.pubkey(), + &split_stake_account_address, + rent_exempt_reserve, + )]; + if let Some(seed) = split_stake_account_seed { + ixs.append( + &mut stake_instruction::split_with_seed( + stake_account_pubkey, + &stake_authority.pubkey(), + lamports, + &split_stake_account_address, + &split_stake_account.pubkey(), + seed, + ) + .with_memo(memo) + .with_compute_unit_price(compute_unit_price), ) - .with_memo(memo) - .with_compute_unit_price(compute_unit_price) } else { - stake_instruction::split( - stake_account_pubkey, - &stake_authority.pubkey(), - lamports, - &split_stake_account_address, + ixs.append( + &mut stake_instruction::split( + stake_account_pubkey, + &stake_authority.pubkey(), + lamports, + &split_stake_account_address, + ) + .with_memo(memo) + .with_compute_unit_price(compute_unit_price), ) - .with_memo(memo) - .with_compute_unit_price(compute_unit_price) }; let nonce_authority = config.signers[nonce_authority]; @@ -4848,6 +4882,7 @@ mod tests { lamports: 50_000_000_000, fee_payer: 0, compute_unit_price: None, + rent_exempt_reserve: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -4915,6 +4950,7 @@ mod tests { lamports: 50_000_000_000, fee_payer: 1, compute_unit_price: None, + rent_exempt_reserve: None, }, signers: vec![ Presigner::new(&stake_auth_pubkey, &stake_sig).into(), diff --git a/cli/tests/stake.rs b/cli/tests/stake.rs index fe1396db6c..5984e1d0ce 100644 --- a/cli/tests/stake.rs +++ b/cli/tests/stake.rs @@ -1469,6 +1469,10 @@ fn test_stake_split() { config.json_rpc_url = test_validator.rpc_url(); config.signers = vec![&default_signer]; + let minimum_balance = rpc_client + .get_minimum_balance_for_rent_exemption(StakeStateV2::size_of()) + .unwrap(); + let mut config_offline = CliConfig::recent_for_tests(); config_offline.json_rpc_url = String::default(); config_offline.signers = vec![&offline_signer]; @@ -1496,10 +1500,7 @@ fn test_stake_split() { check_balance!(1_000_000_000_000, &rpc_client, &offline_pubkey); // Create stake account, identity is authority - let stake_balance = rpc_client - .get_minimum_balance_for_rent_exemption(StakeStateV2::size_of()) - .unwrap() - + 10_000_000_000; + let stake_balance = minimum_balance + 10_000_000_000; let stake_keypair = keypair_from_seed(&[0u8; 32]).unwrap(); let stake_account_pubkey = stake_keypair.pubkey(); config.signers.push(&stake_keypair); @@ -1569,6 +1570,7 @@ fn test_stake_split() { lamports: 2 * stake_balance, fee_payer: 0, compute_unit_price: None, + rent_exempt_reserve: Some(minimum_balance), }; config_offline.output_format = OutputFormat::JsonCompact; let sig_response = process_command(&config_offline).unwrap(); @@ -1593,10 +1595,15 @@ fn test_stake_split() { lamports: 2 * stake_balance, fee_payer: 0, compute_unit_price: None, + rent_exempt_reserve: None, }; process_command(&config).unwrap(); - check_balance!(8 * stake_balance, &rpc_client, &stake_account_pubkey,); - check_balance!(2 * stake_balance, &rpc_client, &split_account.pubkey(),); + check_balance!(8 * stake_balance, &rpc_client, &stake_account_pubkey); + check_balance!( + 2 * stake_balance + minimum_balance, + &rpc_client, + &split_account.pubkey() + ); } #[test] diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index c268009885..6cf5f00074 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -527,6 +527,12 @@ mod tests { feature_set } + fn feature_set_without_require_rent_exempt_split_destination() -> Arc { + let mut feature_set = FeatureSet::all_enabled(); + feature_set.deactivate(&feature_set::require_rent_exempt_split_destination::id()); + Arc::new(feature_set) + } + fn create_default_account() -> AccountSharedData { AccountSharedData::new(0, 0, &Pubkey::new_unique()) } @@ -638,6 +644,25 @@ mod tests { ) } + fn get_active_stake_for_tests( + stake_accounts: &[AccountSharedData], + clock: &Clock, + stake_history: &StakeHistory, + ) -> u64 { + let mut active_stake = 0; + for account in stake_accounts { + if let StakeStateV2::Stake(_meta, stake, _stake_flags) = account.state().unwrap() { + let stake_status = stake.delegation.stake_activating_and_deactivating( + clock.epoch, + Some(stake_history), + None, + ); + active_stake += stake_status.effective; + } + } + active_stake + } + #[test_case(feature_set_old_warmup_cooldown_no_minimum_delegation(); "old_warmup_cooldown_no_min_delegation")] #[test_case(feature_set_old_warmup_cooldown(); "old_warmup_cooldown")] #[test_case(feature_set_all_enabled(); "all_enabled")] @@ -2704,6 +2729,12 @@ mod tests { #[test_case(feature_set_old_warmup_cooldown(); "old_warmup_cooldown")] #[test_case(feature_set_all_enabled(); "all_enabled")] fn test_split(feature_set: Arc) { + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let stake_address = solana_sdk::pubkey::new_rand(); let minimum_delegation = crate::get_minimum_delegation(&feature_set); let stake_lamports = minimum_delegation * 2; @@ -2717,7 +2748,7 @@ mod tests { .unwrap(); let mut transaction_accounts = vec![ (stake_address, AccountSharedData::default()), - (split_to_address, split_to_account), + (split_to_address, split_to_account.clone()), ( rent::id(), create_account_shared_data_for_test(&Rent { @@ -2725,6 +2756,15 @@ mod tests { ..Rent::default() }), ), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ]; let instruction_accounts = vec![ AccountMeta { @@ -2752,6 +2792,11 @@ mod tests { &id(), ) .unwrap(); + let expected_active_stake = get_active_stake_for_tests( + &[stake_account.clone(), split_to_account.clone()], + &clock, + &stake_history, + ); transaction_accounts[0] = (stake_address, stake_account); // should fail, split more than available @@ -2777,6 +2822,12 @@ mod tests { stake_lamports ); + // no deactivated stake + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); + assert_eq!(from(&accounts[0]).unwrap(), from(&accounts[1]).unwrap()); match state { StakeStateV2::Initialized(_meta) => { @@ -4046,6 +4097,12 @@ mod tests { let minimum_delegation = crate::get_minimum_delegation(&feature_set); let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let source_address = Pubkey::new_unique(); let source_meta = Meta { rent_exempt_reserve, @@ -4053,7 +4110,7 @@ mod tests { }; let dest_address = Pubkey::new_unique(); let dest_account = AccountSharedData::new_data_with_space( - 0, + rent_exempt_reserve, &StakeStateV2::Uninitialized, StakeStateV2::size_of(), &id(), @@ -4071,57 +4128,60 @@ mod tests { is_writable: true, }, ]; - for (source_reserve, dest_reserve, expected_result) in [ - (rent_exempt_reserve, rent_exempt_reserve, Ok(())), + for (source_delegation, split_amount, expected_result) in [ + (minimum_delegation * 2, minimum_delegation, Ok(())), ( - rent_exempt_reserve, - rent_exempt_reserve - 1, + minimum_delegation * 2, + minimum_delegation - 1, Err(InstructionError::InsufficientFunds), ), ( - rent_exempt_reserve - 1, - rent_exempt_reserve, + (minimum_delegation * 2) - 1, + minimum_delegation, Err(InstructionError::InsufficientFunds), ), ( - rent_exempt_reserve - 1, - rent_exempt_reserve - 1, + (minimum_delegation - 1) * 2, + minimum_delegation - 1, Err(InstructionError::InsufficientFunds), ), ] { - // The source account's starting balance is equal to *both* the source and dest - // accounts' *final* balance - let mut source_starting_balance = source_reserve + dest_reserve; - for (delegation, source_stake_state) in &[ - (0, StakeStateV2::Initialized(source_meta)), - ( - minimum_delegation, - just_stake( - source_meta, - minimum_delegation * 2 + source_starting_balance - rent_exempt_reserve, + let source_account = AccountSharedData::new_data_with_space( + source_delegation + rent_exempt_reserve, + &just_stake(source_meta, source_delegation), + StakeStateV2::size_of(), + &id(), + ) + .unwrap(); + let expected_active_stake = get_active_stake_for_tests( + &[source_account.clone(), dest_account.clone()], + &clock, + &stake_history, + ); + let accounts = process_instruction( + Arc::clone(&feature_set), + &serialize(&StakeInstruction::Split(split_amount)).unwrap(), + vec![ + (source_address, source_account), + (dest_address, dest_account.clone()), + (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), ), - ), - ] { - source_starting_balance += delegation * 2; - let source_account = AccountSharedData::new_data_with_space( - source_starting_balance, - source_stake_state, - StakeStateV2::size_of(), - &id(), - ) - .unwrap(); - process_instruction( - Arc::clone(&feature_set), - &serialize(&StakeInstruction::Split(dest_reserve + delegation)).unwrap(), - vec![ - (source_address, source_account), - (dest_address, dest_account.clone()), - (rent::id(), create_account_shared_data_for_test(&rent)), - ], - instruction_accounts.clone(), - expected_result.clone(), - ); - } + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), + ], + instruction_accounts.clone(), + expected_result.clone(), + ); + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); } } @@ -4139,6 +4199,12 @@ mod tests { let minimum_delegation = crate::get_minimum_delegation(&feature_set); let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let source_address = Pubkey::new_unique(); let source_meta = Meta { rent_exempt_reserve, @@ -4185,17 +4251,35 @@ mod tests { &id(), ) .unwrap(); - process_instruction( + let expected_active_stake = get_active_stake_for_tests( + &[source_account.clone(), dest_account.clone()], + &clock, + &stake_history, + ); + let accounts = process_instruction( Arc::clone(&feature_set), &serialize(&StakeInstruction::Split(source_account.lamports())).unwrap(), vec![ (source_address, source_account), (dest_address, dest_account.clone()), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ], instruction_accounts.clone(), expected_result.clone(), ); + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); } } } @@ -4308,6 +4392,12 @@ mod tests { let minimum_delegation = crate::get_minimum_delegation(&feature_set); let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let source_address = Pubkey::new_unique(); let destination_address = Pubkey::new_unique(); let instruction_accounts = vec![ @@ -4359,17 +4449,26 @@ mod tests { minimum_delegation.saturating_sub(1), // when minimum is 0, this blows up! Err(InstructionError::InsufficientFunds), ), - // destination is not rent exempt, so split enough for rent and minimum delegation - (rent_exempt_reserve - 1, minimum_delegation + 1, Ok(())), + // destination is not rent exempt, so any split amount fails, including enough for rent + // and minimum delegation + ( + rent_exempt_reserve - 1, + minimum_delegation + 1, + Err(InstructionError::InsufficientFunds), + ), // destination is not rent exempt, but split amount only for minimum delegation ( rent_exempt_reserve - 1, minimum_delegation, Err(InstructionError::InsufficientFunds), ), - // destination has smallest non-zero balance, so can split the minimum balance - // requirements minus what destination already has - (1, rent_exempt_reserve + minimum_delegation - 1, Ok(())), + // destination is not rent exempt, so any split amount fails, including case where + // destination has smallest non-zero balance + ( + 1, + rent_exempt_reserve + minimum_delegation - 1, + Err(InstructionError::InsufficientFunds), + ), // destination has smallest non-zero balance, but cannot split less than the minimum // balance requirements minus what destination already has ( @@ -4377,9 +4476,13 @@ mod tests { rent_exempt_reserve + minimum_delegation - 2, Err(InstructionError::InsufficientFunds), ), - // destination has zero lamports, so split must be at least rent exempt reserve plus - // minimum delegation - (0, rent_exempt_reserve + minimum_delegation, Ok(())), + // destination has zero lamports, so any split amount fails, including at least rent + // exempt reserve plus minimum delegation + ( + 0, + rent_exempt_reserve + minimum_delegation, + Err(InstructionError::InsufficientFunds), + ), // destination has zero lamports, but split amount is less than rent exempt reserve // plus minimum delegation ( @@ -4410,6 +4513,11 @@ mod tests { &id(), ) .unwrap(); + let expected_active_stake = get_active_stake_for_tests( + &[source_account.clone(), destination_account.clone()], + &clock, + &stake_history, + ); let accounts = process_instruction( Arc::clone(&feature_set), &serialize(&StakeInstruction::Split(split_amount)).unwrap(), @@ -4417,10 +4525,23 @@ mod tests { (source_address, source_account.clone()), (destination_address, destination_account), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ], instruction_accounts.clone(), expected_result.clone(), ); + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); // For the expected OK cases, when the source's StakeStateV2 is Stake, then the // destination's StakeStateV2 *must* also end up as Stake as well. Additionally, // check to ensure the destination's delegation amount is correct. If the @@ -4892,6 +5013,8 @@ mod tests { fn test_split_more_than_staked(feature_set: Arc) { let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; let minimum_delegation = crate::get_minimum_delegation(&feature_set); let stake_lamports = (rent_exempt_reserve + minimum_delegation) * 2; let stake_address = solana_sdk::pubkey::new_rand(); @@ -4910,7 +5033,7 @@ mod tests { .unwrap(); let split_to_address = solana_sdk::pubkey::new_rand(); let split_to_account = AccountSharedData::new_data_with_space( - 0, + rent_exempt_reserve, &StakeStateV2::Uninitialized, StakeStateV2::size_of(), &id(), @@ -4920,6 +5043,21 @@ mod tests { (stake_address, stake_account), (split_to_address, split_to_account), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + ( + clock::id(), + create_account_shared_data_for_test(&Clock { + epoch: current_epoch, + ..Clock::default() + }), + ), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ]; let instruction_accounts = vec![ AccountMeta { @@ -4949,6 +5087,12 @@ mod tests { fn test_split_with_rent(feature_set: Arc) { let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let minimum_delegation = crate::get_minimum_delegation(&feature_set); let stake_address = solana_sdk::pubkey::new_rand(); let split_to_address = solana_sdk::pubkey::new_rand(); @@ -4993,10 +5137,24 @@ mod tests { &id(), ) .unwrap(); + let expected_active_stake = get_active_stake_for_tests( + &[stake_account.clone(), split_to_account.clone()], + &clock, + &stake_history, + ); let mut transaction_accounts = vec![ (stake_address, stake_account), (split_to_address, split_to_account.clone()), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ]; // not enough to make a non-zero stake account @@ -5020,7 +5178,7 @@ mod tests { Err(InstructionError::InsufficientFunds), ); - // split account already has way enough lamports + // split account already has enough lamports transaction_accounts[1].1.set_lamports(*minimum_balance); let accounts = process_instruction( Arc::clone(&feature_set), @@ -5029,6 +5187,10 @@ mod tests { instruction_accounts.clone(), Ok(()), ); + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); // verify no stake leakage in the case of a stake if let StakeStateV2::Stake(meta, stake, stake_flags) = state { @@ -5058,6 +5220,12 @@ mod tests { fn test_split_to_account_with_rent_exempt_reserve(feature_set: Arc) { let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let minimum_delegation = crate::get_minimum_delegation(&feature_set); let stake_lamports = (rent_exempt_reserve + minimum_delegation) * 2; let stake_address = solana_sdk::pubkey::new_rand(); @@ -5088,17 +5256,7 @@ mod tests { }, ]; - // Test various account prefunding, including empty, less than rent_exempt_reserve, exactly - // rent_exempt_reserve, and more than rent_exempt_reserve. The empty case is not covered in - // test_split, since that test uses a Meta with rent_exempt_reserve = 0 - let split_lamport_balances = vec![ - 0, - rent_exempt_reserve - 1, - rent_exempt_reserve, - rent_exempt_reserve + minimum_delegation - 1, - rent_exempt_reserve + minimum_delegation, - ]; - for initial_balance in split_lamport_balances { + let transaction_accounts = |initial_balance: u64| -> Vec<(Pubkey, AccountSharedData)> { let split_to_account = AccountSharedData::new_data_with_space( initial_balance, &StakeStateV2::Uninitialized, @@ -5106,11 +5264,63 @@ mod tests { &id(), ) .unwrap(); - let transaction_accounts = vec![ + vec![ (stake_address, stake_account.clone()), (split_to_address, split_to_account), (rent::id(), create_account_shared_data_for_test(&rent)), - ]; + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), + ] + }; + + // Test insufficient account prefunding, including empty and less than rent_exempt_reserve. + // The empty case is not covered in test_split, since that test uses a Meta with + // rent_exempt_reserve = 0 + let split_lamport_balances = vec![0, rent_exempt_reserve - 1]; + for initial_balance in split_lamport_balances { + let transaction_accounts = transaction_accounts(initial_balance); + // split more than available fails + process_instruction( + Arc::clone(&feature_set), + &serialize(&StakeInstruction::Split(stake_lamports + 1)).unwrap(), + transaction_accounts.clone(), + instruction_accounts.clone(), + Err(InstructionError::InsufficientFunds), + ); + // split to insufficiently funded dest fails + process_instruction( + Arc::clone(&feature_set), + &serialize(&StakeInstruction::Split(stake_lamports / 2)).unwrap(), + transaction_accounts, + instruction_accounts.clone(), + Err(InstructionError::InsufficientFunds), + ); + } + + // Test various account prefunding, including exactly rent_exempt_reserve, and more than + // rent_exempt_reserve + let split_lamport_balances = vec![ + rent_exempt_reserve, + rent_exempt_reserve + minimum_delegation - 1, + rent_exempt_reserve + minimum_delegation, + ]; + for initial_balance in split_lamport_balances { + let transaction_accounts = transaction_accounts(initial_balance); + let expected_active_stake = get_active_stake_for_tests( + &[ + transaction_accounts[0].1.clone(), + transaction_accounts[1].1.clone(), + ], + &clock, + &stake_history, + ); // split more than available fails process_instruction( @@ -5134,6 +5344,11 @@ mod tests { accounts[0].lamports() + accounts[1].lamports(), stake_lamports + initial_balance, ); + // no deactivated stake + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); if let StakeStateV2::Stake(meta, stake, stake_flags) = state { let expected_stake = @@ -5184,6 +5399,12 @@ mod tests { let rent = Rent::default(); let source_larger_rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of() + 100); let split_rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let minimum_delegation = crate::get_minimum_delegation(&feature_set); let stake_lamports = (source_larger_rent_exempt_reserve + minimum_delegation) * 2; let stake_address = solana_sdk::pubkey::new_rand(); @@ -5214,17 +5435,7 @@ mod tests { }, ]; - // Test various account prefunding, including empty, less than rent_exempt_reserve, exactly - // rent_exempt_reserve, and more than rent_exempt_reserve. The empty case is not covered in - // test_split, since that test uses a Meta with rent_exempt_reserve = 0 - let split_lamport_balances = vec![ - 0, - split_rent_exempt_reserve - 1, - split_rent_exempt_reserve, - split_rent_exempt_reserve + minimum_delegation - 1, - split_rent_exempt_reserve + minimum_delegation, - ]; - for initial_balance in split_lamport_balances { + let transaction_accounts = |initial_balance: u64| -> Vec<(Pubkey, AccountSharedData)> { let split_to_account = AccountSharedData::new_data_with_space( initial_balance, &StakeStateV2::Uninitialized, @@ -5232,11 +5443,52 @@ mod tests { &id(), ) .unwrap(); - let transaction_accounts = vec![ + vec![ (stake_address, stake_account.clone()), (split_to_address, split_to_account), (rent::id(), create_account_shared_data_for_test(&rent)), - ]; + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), + ] + }; + + // Test insufficient account prefunding, including empty and less than rent_exempt_reserve + let split_lamport_balances = vec![0, split_rent_exempt_reserve - 1]; + for initial_balance in split_lamport_balances { + process_instruction( + Arc::clone(&feature_set), + &serialize(&StakeInstruction::Split(stake_lamports / 2)).unwrap(), + transaction_accounts(initial_balance), + instruction_accounts.clone(), + Err(InstructionError::InsufficientFunds), + ); + } + + // Test various account prefunding, including exactly rent_exempt_reserve, and more than + // rent_exempt_reserve. The empty case is not covered in test_split, since that test uses a + // Meta with rent_exempt_reserve = 0 + let split_lamport_balances = vec![ + split_rent_exempt_reserve, + split_rent_exempt_reserve + minimum_delegation - 1, + split_rent_exempt_reserve + minimum_delegation, + ]; + for initial_balance in split_lamport_balances { + let transaction_accounts = transaction_accounts(initial_balance); + let expected_active_stake = get_active_stake_for_tests( + &[ + transaction_accounts[0].1.clone(), + transaction_accounts[1].1.clone(), + ], + &clock, + &stake_history, + ); // split more than available fails process_instruction( @@ -5260,6 +5512,11 @@ mod tests { accounts[0].lamports() + accounts[1].lamports(), stake_lamports + initial_balance ); + // no deactivated stake + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); if let StakeStateV2::Stake(meta, stake, stake_flags) = state { let expected_split_meta = Meta { @@ -5315,6 +5572,8 @@ mod tests { let rent = Rent::default(); let source_smaller_rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); let split_rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of() + 100); + let stake_history = StakeHistory::default(); + let current_epoch = 100; let stake_lamports = split_rent_exempt_reserve + 1; let stake_address = solana_sdk::pubkey::new_rand(); let meta = Meta { @@ -5363,6 +5622,21 @@ mod tests { (stake_address, stake_account.clone()), (split_to_address, split_to_account), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + ( + clock::id(), + create_account_shared_data_for_test(&Clock { + epoch: current_epoch, + ..Clock::default() + }), + ), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ]; // should always return error when splitting to larger account @@ -5391,6 +5665,12 @@ mod tests { fn test_split_100_percent_of_source(feature_set: Arc) { let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let minimum_delegation = crate::get_minimum_delegation(&feature_set); let stake_lamports = rent_exempt_reserve + minimum_delegation; let stake_address = solana_sdk::pubkey::new_rand(); @@ -5432,10 +5712,24 @@ mod tests { &id(), ) .unwrap(); + let expected_active_stake = get_active_stake_for_tests( + &[stake_account.clone(), split_to_account.clone()], + &clock, + &stake_history, + ); let transaction_accounts = vec![ (stake_address, stake_account), (split_to_address, split_to_account.clone()), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ]; // split 100% over to dest @@ -5452,6 +5746,11 @@ mod tests { accounts[0].lamports() + accounts[1].lamports(), stake_lamports ); + // no deactivated stake + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); match state { StakeStateV2::Initialized(_) => { @@ -5486,6 +5785,12 @@ mod tests { fn test_split_100_percent_of_source_to_account_with_lamports(feature_set: Arc) { let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let minimum_delegation = crate::get_minimum_delegation(&feature_set); let stake_lamports = rent_exempt_reserve + minimum_delegation; let stake_address = solana_sdk::pubkey::new_rand(); @@ -5534,10 +5839,24 @@ mod tests { &id(), ) .unwrap(); + let expected_active_stake = get_active_stake_for_tests( + &[stake_account.clone(), split_to_account.clone()], + &clock, + &stake_history, + ); let transaction_accounts = vec![ (stake_address, stake_account.clone()), (split_to_address, split_to_account), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ]; // split 100% over to dest @@ -5554,6 +5873,11 @@ mod tests { accounts[0].lamports() + accounts[1].lamports(), stake_lamports + initial_balance ); + // no deactivated stake + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); if let StakeStateV2::Stake(meta, stake, stake_flags) = state { assert_eq!( @@ -5582,6 +5906,12 @@ mod tests { let rent = Rent::default(); let source_rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of() + 100); let split_rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; let minimum_delegation = crate::get_minimum_delegation(&feature_set); let stake_lamports = source_rent_exempt_reserve + minimum_delegation; let stake_address = solana_sdk::pubkey::new_rand(); @@ -5627,6 +5957,15 @@ mod tests { (stake_address, stake_account), (split_to_address, split_to_account), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ]; process_instruction( Arc::clone(&feature_set), @@ -5652,10 +5991,30 @@ mod tests { &id(), ) .unwrap(); + let expected_active_stake = get_active_stake_for_tests( + &[stake_account.clone(), split_to_account.clone()], + &clock, + &stake_history, + ); let transaction_accounts = vec![ (stake_address, stake_account), (split_to_address, split_to_account), (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + ( + clock::id(), + create_account_shared_data_for_test(&Clock { + epoch: current_epoch, + ..Clock::default() + }), + ), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), ]; let accounts = process_instruction( Arc::clone(&feature_set), @@ -5665,6 +6024,10 @@ mod tests { Ok(()), ); assert_eq!(accounts[1].lamports(), stake_lamports); + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); let expected_split_meta = Meta { authorized: Authorized::auto(&stake_address), @@ -5709,6 +6072,200 @@ mod tests { } } + #[test_case(feature_set_without_require_rent_exempt_split_destination(), Ok(()); "without_require_rent_exempt_split_destination")] + #[test_case(feature_set_all_enabled(), Err(InstructionError::InsufficientFunds); "all_enabled")] + fn test_split_require_rent_exempt_destination( + feature_set: Arc, + expected_result: Result<(), InstructionError>, + ) { + let rent = Rent::default(); + let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let stake_history = StakeHistory::default(); + let current_epoch = 100; + let clock = Clock { + epoch: current_epoch, + ..Clock::default() + }; + let minimum_delegation = crate::get_minimum_delegation(&feature_set); + let delegation_amount = 3 * minimum_delegation; + let source_lamports = rent_exempt_reserve + delegation_amount; + let source_address = Pubkey::new_unique(); + let destination_address = Pubkey::new_unique(); + let meta = Meta { + authorized: Authorized::auto(&source_address), + rent_exempt_reserve, + ..Meta::default() + }; + let instruction_accounts = vec![ + AccountMeta { + pubkey: source_address, + is_signer: true, + is_writable: true, + }, + AccountMeta { + pubkey: destination_address, + is_signer: false, + is_writable: true, + }, + ]; + + for (split_amount, expected_result) in [ + (2 * minimum_delegation, expected_result), + (source_lamports, Ok(())), + ] { + for (state, expected_result) in &[ + (StakeStateV2::Initialized(meta), Ok(())), + (just_stake(meta, delegation_amount), expected_result), + ] { + let source_account = AccountSharedData::new_data_with_space( + source_lamports, + &state, + StakeStateV2::size_of(), + &id(), + ) + .unwrap(); + + let transaction_accounts = + |initial_balance: u64| -> Vec<(Pubkey, AccountSharedData)> { + let destination_account = AccountSharedData::new_data_with_space( + initial_balance, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of(), + &id(), + ) + .unwrap(); + vec![ + (source_address, source_account.clone()), + (destination_address, destination_account), + (rent::id(), create_account_shared_data_for_test(&rent)), + ( + stake_history::id(), + create_account_shared_data_for_test(&stake_history), + ), + (clock::id(), create_account_shared_data_for_test(&clock)), + ( + epoch_schedule::id(), + create_account_shared_data_for_test(&EpochSchedule::default()), + ), + ] + }; + + // Test insufficient recipient prefunding; should error once feature is activated + let split_lamport_balances = vec![0, rent_exempt_reserve - 1]; + for initial_balance in split_lamport_balances { + let transaction_accounts = transaction_accounts(initial_balance); + let expected_active_stake = get_active_stake_for_tests( + &[source_account.clone(), transaction_accounts[1].1.clone()], + &clock, + &stake_history, + ); + let result_accounts = process_instruction( + Arc::clone(&feature_set), + &serialize(&StakeInstruction::Split(split_amount)).unwrap(), + transaction_accounts.clone(), + instruction_accounts.clone(), + expected_result.clone(), + ); + let result_active_stake = + get_active_stake_for_tests(&result_accounts[0..2], &clock, &stake_history); + if expected_active_stake > 0 // starting stake was delegated + // partial split + && result_accounts[0].lamports() > 0 + // successful split to deficient recipient + && expected_result.is_ok() + { + assert_ne!(expected_active_stake, result_active_stake); + } else { + assert_eq!(expected_active_stake, result_active_stake); + } + } + + // Test recipient prefunding, including exactly rent_exempt_reserve, and more than + // rent_exempt_reserve. + let split_lamport_balances = vec![rent_exempt_reserve, rent_exempt_reserve + 1]; + for initial_balance in split_lamport_balances { + let transaction_accounts = transaction_accounts(initial_balance); + let expected_active_stake = get_active_stake_for_tests( + &[source_account.clone(), transaction_accounts[1].1.clone()], + &clock, + &stake_history, + ); + let accounts = process_instruction( + Arc::clone(&feature_set), + &serialize(&StakeInstruction::Split(split_amount)).unwrap(), + transaction_accounts, + instruction_accounts.clone(), + Ok(()), + ); + + // no lamport leakage + assert_eq!( + accounts[0].lamports() + accounts[1].lamports(), + source_lamports + initial_balance + ); + + // no deactivated stake + assert_eq!( + expected_active_stake, + get_active_stake_for_tests(&accounts[0..2], &clock, &stake_history) + ); + + if let StakeStateV2::Stake(meta, stake, stake_flags) = state { + // split entire source account, including rent-exempt reserve + if accounts[0].lamports() == 0 { + assert_eq!(Ok(StakeStateV2::Uninitialized), accounts[0].state()); + assert_eq!( + Ok(StakeStateV2::Stake( + *meta, + Stake { + delegation: Delegation { + // delegated amount should not include source + // rent-exempt reserve + stake: delegation_amount, + ..stake.delegation + }, + ..*stake + }, + *stake_flags, + )), + accounts[1].state() + ); + } else { + assert_eq!( + Ok(StakeStateV2::Stake( + *meta, + Stake { + delegation: Delegation { + stake: minimum_delegation, + ..stake.delegation + }, + ..*stake + }, + *stake_flags, + )), + accounts[0].state() + ); + assert_eq!( + Ok(StakeStateV2::Stake( + *meta, + Stake { + delegation: Delegation { + stake: split_amount, + ..stake.delegation + }, + ..*stake + }, + *stake_flags, + )), + accounts[1].state() + ); + } + } + } + } + } + } + #[test_case(feature_set_old_warmup_cooldown_no_minimum_delegation(); "old_warmup_cooldown_no_min_delegation")] #[test_case(feature_set_old_warmup_cooldown(); "old_warmup_cooldown")] #[test_case(feature_set_all_enabled(); "all_enabled")] diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index 713054ae62..964d2d6ffc 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -103,6 +103,19 @@ pub(crate) fn new_warmup_cooldown_rate_epoch(invoke_context: &InvokeContext) -> .new_warmup_cooldown_rate_epoch(epoch_schedule.as_ref()) } +fn get_stake_status( + invoke_context: &InvokeContext, + stake: &Stake, + clock: &Clock, +) -> Result { + let stake_history = invoke_context.get_sysvar_cache().get_stake_history()?; + Ok(stake.delegation.stake_activating_and_deactivating( + clock.epoch, + Some(&stake_history), + new_warmup_cooldown_rate_epoch(invoke_context), + )) +} + fn redelegate_stake( invoke_context: &InvokeContext, stake: &mut Stake, @@ -688,6 +701,16 @@ pub fn split( StakeStateV2::Stake(meta, mut stake, stake_flags) => { meta.authorized.check(signers, StakeAuthorize::Staker)?; let minimum_delegation = crate::get_minimum_delegation(&invoke_context.feature_set); + let is_active = if invoke_context + .feature_set + .is_active(&feature_set::require_rent_exempt_split_destination::id()) + { + let clock = invoke_context.get_sysvar_cache().get_clock()?; + let status = get_stake_status(invoke_context, &stake, &clock)?; + status.effective > 0 + } else { + false + }; let validated_split_info = validate_split_amount( invoke_context, transaction_context, @@ -697,6 +720,7 @@ pub fn split( lamports, &meta, minimum_delegation, + is_active, )?; // split the stake, subtract rent_exempt_balance unless @@ -763,6 +787,7 @@ pub fn split( lamports, &meta, 0, // additional_required_lamports + false, )?; let mut split_meta = meta; split_meta.rent_exempt_reserve = validated_split_info.destination_rent_exempt_reserve; @@ -925,12 +950,7 @@ pub fn redelegate( let (stake_meta, effective_stake) = if let StakeStateV2::Stake(meta, stake, _stake_flags) = stake_account.get_state()? { - let stake_history = invoke_context.get_sysvar_cache().get_stake_history()?; - let status = stake.delegation.stake_activating_and_deactivating( - clock.epoch, - Some(&stake_history), - new_warmup_cooldown_rate_epoch(invoke_context), - ); + let status = get_stake_status(invoke_context, &stake, &clock)?; if status.effective == 0 || status.activating != 0 || status.deactivating != 0 { ic_msg!(invoke_context, "stake is not active"); return Err(StakeError::RedelegateTransientOrInactiveStake.into()); @@ -1192,6 +1212,7 @@ fn validate_split_amount( lamports: u64, source_meta: &Meta, additional_required_lamports: u64, + source_is_active: bool, ) -> Result { let source_account = instruction_context .try_borrow_instruction_account(transaction_context, source_account_index)?; @@ -1232,12 +1253,27 @@ fn validate_split_amount( // nothing to do here } + let rent = invoke_context.get_sysvar_cache().get_rent()?; + let destination_rent_exempt_reserve = rent.minimum_balance(destination_data_len); + + // As of feature `require_rent_exempt_split_destination`, if the source is active stake, one of + // these criteria must be met: + // 1. the destination account must be prefunded with at least the rent-exempt reserve, or + // 2. the split must consume 100% of the source + if invoke_context + .feature_set + .is_active(&feature_set::require_rent_exempt_split_destination::id()) + && source_is_active + && source_remaining_balance != 0 + && destination_lamports < destination_rent_exempt_reserve + { + return Err(InstructionError::InsufficientFunds); + } + // Verify the destination account meets the minimum balance requirements // This must handle: // 1. The destination account having a different rent exempt reserve due to data size changes // 2. The destination account being prefunded, which would lower the minimum split amount - let rent = invoke_context.get_sysvar_cache().get_rent()?; - let destination_rent_exempt_reserve = rent.minimum_balance(destination_data_len); let destination_minimum_balance = destination_rent_exempt_reserve.saturating_add(additional_required_lamports); let destination_balance_deficit = diff --git a/runtime/tests/stake.rs b/runtime/tests/stake.rs index c260fead02..7088e6438e 100755 --- a/runtime/tests/stake.rs +++ b/runtime/tests/stake.rs @@ -428,15 +428,21 @@ fn test_stake_account_lifetime() { let split_stake_keypair = Keypair::new(); let split_stake_pubkey = split_stake_keypair.pubkey(); + bank.transfer( + stake_rent_exempt_reserve, + &mint_keypair, + &split_stake_pubkey, + ) + .unwrap(); let bank_client = BankClient::new_shared(bank.clone()); + // Test split let split_starting_delegation = stake_minimum_delegation + bonus_delegation; - let split_starting_balance = split_starting_delegation + stake_rent_exempt_reserve; let message = Message::new( &stake_instruction::split( &stake_pubkey, &stake_pubkey, - split_starting_balance, + split_starting_delegation, &split_stake_pubkey, ), Some(&mint_pubkey), @@ -451,7 +457,7 @@ fn test_stake_account_lifetime() { get_staked(&bank, &split_stake_pubkey), split_starting_delegation, ); - let stake_remaining_balance = balance - split_starting_balance; + let stake_remaining_balance = balance - split_starting_delegation; // Deactivate the split let message = Message::new( diff --git a/sdk/src/feature_set.rs b/sdk/src/feature_set.rs index 95ea3f3b6c..e74883ec93 100644 --- a/sdk/src/feature_set.rs +++ b/sdk/src/feature_set.rs @@ -689,6 +689,10 @@ pub mod enable_program_runtime_v2_and_loader_v4 { solana_sdk::declare_id!("8oBxsYqnCvUTGzgEpxPcnVf7MLbWWPYddE33PftFeBBd"); } +pub mod require_rent_exempt_split_destination { + solana_sdk::declare_id!("D2aip4BBr8NPWtU9vLrwrBvbuaQ8w1zV38zFLxx4pfBV"); +} + lazy_static! { /// Map of feature identifiers to user-visible description pub static ref FEATURE_NAMES: HashMap = [ @@ -856,6 +860,7 @@ lazy_static! { (timely_vote_credits::id(), "use timeliness of votes in determining credits to award"), (remaining_compute_units_syscall_enabled::id(), "enable the remaining_compute_units syscall"), (enable_program_runtime_v2_and_loader_v4::id(), "Enable Program-Runtime-v2 and Loader-v4 #33293"), + (require_rent_exempt_split_destination::id(), "Require stake split destination account to be rent exempt"), /*************** ADD NEW FEATURES HERE ***************/ ] .iter() diff --git a/tokens/src/arg_parser.rs b/tokens/src/arg_parser.rs index e40b29237c..924c4e3e8e 100644 --- a/tokens/src/arg_parser.rs +++ b/tokens/src/arg_parser.rs @@ -559,6 +559,7 @@ fn parse_distribute_stake_args( stake_authority, withdraw_authority, lockup_authority, + rent_exempt_reserve: None, }; let stake_args = StakeArgs { unlocked_sol: sol_to_lamports(value_t_or_exit!(matches, "unlocked_sol", f64)), diff --git a/tokens/src/args.rs b/tokens/src/args.rs index b1f1522e15..0dd4859f51 100644 --- a/tokens/src/args.rs +++ b/tokens/src/args.rs @@ -5,6 +5,7 @@ pub struct SenderStakeArgs { pub stake_authority: Box, pub withdraw_authority: Box, pub lockup_authority: Option>, + pub rent_exempt_reserve: Option, } pub struct StakeArgs { diff --git a/tokens/src/commands.rs b/tokens/src/commands.rs index 5b2603814b..c10ad508d6 100644 --- a/tokens/src/commands.rs +++ b/tokens/src/commands.rs @@ -31,7 +31,7 @@ use { signature::{unique_signers, Signature, Signer}, stake::{ instruction::{self as stake_instruction, LockupArgs}, - state::{Authorized, Lockup, StakeAuthorize}, + state::{Authorized, Lockup, StakeAuthorize, StakeStateV2}, }, system_instruction, transaction::Transaction, @@ -234,12 +234,24 @@ fn distribution_instructions( Some(sender_stake_args) => { let stake_authority = sender_stake_args.stake_authority.pubkey(); let withdraw_authority = sender_stake_args.withdraw_authority.pubkey(); - let mut instructions = stake_instruction::split( + let rent_exempt_reserve = sender_stake_args + .rent_exempt_reserve + .expect("SenderStakeArgs.rent_exempt_reserve should be populated"); + + // Transfer some tokens to stake account to cover rent-exempt reserve. + let mut instructions = vec![system_instruction::transfer( + &sender_pubkey, + new_stake_account_address, + rent_exempt_reserve, + )]; + + // Split to stake account + instructions.append(&mut stake_instruction::split( &sender_stake_args.stake_account_address, &stake_authority, - allocation.amount - unlocked_sol, + allocation.amount - unlocked_sol - rent_exempt_reserve, new_stake_account_address, - ); + )); // Make the recipient the new stake authority instructions.push(stake_instruction::authorize( @@ -1174,11 +1186,15 @@ pub fn test_process_distribute_stake_with_client(client: &RpcClient, sender_keyp let output_file = NamedTempFile::new().unwrap(); let output_path = output_file.path().to_str().unwrap().to_string(); + let rent_exempt_reserve = client + .get_minimum_balance_for_rent_exemption(StakeStateV2::size_of()) + .unwrap(); let sender_stake_args = SenderStakeArgs { stake_account_address, stake_authority: Box::new(stake_authority), withdraw_authority: Box::new(withdraw_authority), lockup_authority: None, + rent_exempt_reserve: Some(rent_exempt_reserve), }; let stake_args = StakeArgs { unlocked_sol: sol_to_lamports(1.0), @@ -1529,14 +1545,14 @@ mod tests { )); // Same recipient, same lockups } - const SET_LOCKUP_INDEX: usize = 5; + const SET_LOCKUP_INDEX: usize = 6; #[test] fn test_set_split_stake_lockup() { let lockup_date_str = "2021-01-07T00:00:00Z"; let allocation = Allocation { recipient: Pubkey::default().to_string(), - amount: sol_to_lamports(1.0), + amount: sol_to_lamports(1.002_282_880), lockup_date: lockup_date_str.to_string(), }; let stake_account_address = solana_sdk::pubkey::new_rand(); @@ -1548,6 +1564,7 @@ mod tests { stake_authority: Box::new(Keypair::new()), withdraw_authority: Box::new(Keypair::new()), lockup_authority: Some(Box::new(lockup_authority)), + rent_exempt_reserve: Some(2_282_880), }; let stake_args = StakeArgs { lockup_authority: Some(lockup_authority_address), @@ -1821,6 +1838,7 @@ mod tests { stake_authority: Box::new(stake_authority), withdraw_authority: Box::new(withdraw_authority), lockup_authority: None, + rent_exempt_reserve: Some(2_282_880), }; StakeArgs { diff --git a/tokens/src/lib.rs b/tokens/src/lib.rs index 0198312abe..2e1e4641bc 100644 --- a/tokens/src/lib.rs +++ b/tokens/src/lib.rs @@ -4,4 +4,5 @@ pub mod args; pub mod commands; mod db; pub mod spl_token; +pub mod stake; pub mod token_display; diff --git a/tokens/src/main.rs b/tokens/src/main.rs index f72278a99f..c97287671d 100644 --- a/tokens/src/main.rs +++ b/tokens/src/main.rs @@ -2,7 +2,7 @@ use { solana_clap_utils::input_validators::normalize_to_url_if_moniker, solana_cli_config::{Config, CONFIG_FILE}, solana_rpc_client::rpc_client::RpcClient, - solana_tokens::{arg_parser::parse_args, args::Command, commands, spl_token}, + solana_tokens::{arg_parser::parse_args, args::Command, commands, spl_token, stake}, std::{ env, error::Error, @@ -43,6 +43,7 @@ fn main() -> Result<(), Box> { match command_args.command { Command::DistributeTokens(mut args) => { spl_token::update_token_args(&client, &mut args.spl_token_args)?; + stake::update_stake_args(&client, &mut args.stake_args)?; commands::process_allocations(&client, &args, exit)?; } Command::Balances(mut args) => { diff --git a/tokens/src/stake.rs b/tokens/src/stake.rs new file mode 100644 index 0000000000..3f1c35a3b4 --- /dev/null +++ b/tokens/src/stake.rs @@ -0,0 +1,15 @@ +use { + crate::{args::StakeArgs, commands::Error}, + solana_rpc_client::rpc_client::RpcClient, + solana_sdk::stake::state::StakeStateV2, +}; + +pub fn update_stake_args(client: &RpcClient, args: &mut Option) -> Result<(), Error> { + if let Some(stake_args) = args { + if let Some(sender_args) = &mut stake_args.sender_stake_args { + let rent = client.get_minimum_balance_for_rent_exemption(StakeStateV2::size_of())?; + sender_args.rent_exempt_reserve = Some(rent); + } + } + Ok(()) +}