diff --git a/.github/workflows/downstream-project-spl.yml b/.github/workflows/downstream-project-spl.yml index 534a3190ffe738..8f5eacaeb7cd00 100644 --- a/.github/workflows/downstream-project-spl.yml +++ b/.github/workflows/downstream-project-spl.yml @@ -130,7 +130,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 a8728cbb1a1da9..a3f851b6df77bd 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -235,6 +235,7 @@ pub enum CliCommand { lamports: u64, fee_payer: SignerIndex, compute_unit_price: Option, + rent_exempt_reserve: Option, }, MergeStake { stake_account_pubkey: Pubkey, @@ -1215,6 +1216,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { lamports, fee_payer, compute_unit_price, + rent_exempt_reserve, } => process_split_stake( &rpc_client, config, @@ -1231,6 +1233,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, @@ -2232,6 +2235,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 f5f90fadd11213..81fa3929413325 100644 --- a/cli/src/stake.rs +++ b/cli/src/stake.rs @@ -53,7 +53,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, }, @@ -119,6 +119,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; } @@ -491,11 +498,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") @@ -1025,6 +1042,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 { @@ -1041,6 +1059,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, }) @@ -1850,6 +1869,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]; @@ -1883,7 +1903,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") @@ -1904,30 +1924,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]; @@ -4845,6 +4879,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(), @@ -4912,6 +4947,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 1ec23d141af3ea..ab08867585b479 100644 --- a/cli/tests/stake.rs +++ b/cli/tests/stake.rs @@ -1467,6 +1467,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(StakeState::size_of()) + .unwrap(); + let mut config_offline = CliConfig::recent_for_tests(); config_offline.json_rpc_url = String::default(); config_offline.signers = vec![&offline_signer]; @@ -1494,10 +1498,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(StakeState::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); @@ -1567,6 +1568,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(); @@ -1591,10 +1593,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 124e079aee920c..78f956c4a60878 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -571,6 +571,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()) } @@ -681,6 +687,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 StakeState::Stake(_meta, stake) = 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")] @@ -2747,6 +2772,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; @@ -2760,7 +2791,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 { @@ -2768,6 +2799,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 { @@ -2795,6 +2835,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 @@ -2820,6 +2865,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 { StakeState::Initialized(_meta) => { @@ -4089,6 +4140,12 @@ mod tests { let minimum_delegation = crate::get_minimum_delegation(&feature_set); let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeState::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, @@ -4096,7 +4153,7 @@ mod tests { }; let dest_address = Pubkey::new_unique(); let dest_account = AccountSharedData::new_data_with_space( - 0, + rent_exempt_reserve, &StakeState::Uninitialized, StakeState::size_of(), &id(), @@ -4114,57 +4171,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, StakeState::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), + StakeState::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, - StakeState::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) + ); } } @@ -4182,6 +4242,12 @@ mod tests { let minimum_delegation = crate::get_minimum_delegation(&feature_set); let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeState::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, @@ -4228,17 +4294,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) + ); } } } @@ -4351,6 +4435,12 @@ mod tests { let minimum_delegation = crate::get_minimum_delegation(&feature_set); let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeState::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![ @@ -4402,17 +4492,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 ( @@ -4420,9 +4519,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 ( @@ -4453,6 +4556,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(), @@ -4460,10 +4568,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 StakeState is Stake, then the // destination's StakeState *must* also end up as Stake as well. Additionally, // check to ensure the destination's delegation amount is correct. If the @@ -4945,6 +5066,8 @@ mod tests { fn test_split_more_than_staked(feature_set: Arc) { let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeState::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(); @@ -4963,7 +5086,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, &StakeState::Uninitialized, StakeState::size_of(), &id(), @@ -4973,6 +5096,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 { @@ -5002,6 +5140,12 @@ mod tests { fn test_split_with_rent(feature_set: Arc) { let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeState::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(); @@ -5046,10 +5190,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 @@ -5073,7 +5231,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), @@ -5082,6 +5240,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 StakeState::Stake(meta, stake) = state { @@ -5110,6 +5272,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(StakeState::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(); @@ -5140,17 +5308,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, &StakeState::Uninitialized, @@ -5158,11 +5316,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( @@ -5186,6 +5396,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 StakeState::Stake(meta, stake) = state { let expected_stake = @@ -5234,6 +5449,12 @@ mod tests { let rent = Rent::default(); let source_larger_rent_exempt_reserve = rent.minimum_balance(StakeState::size_of() + 100); let split_rent_exempt_reserve = rent.minimum_balance(StakeState::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(); @@ -5264,17 +5485,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, &StakeState::Uninitialized, @@ -5282,11 +5493,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( @@ -5310,6 +5562,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 StakeState::Stake(meta, stake) = state { let expected_split_meta = Meta { @@ -5363,6 +5620,8 @@ mod tests { let rent = Rent::default(); let source_smaller_rent_exempt_reserve = rent.minimum_balance(StakeState::size_of()); let split_rent_exempt_reserve = rent.minimum_balance(StakeState::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 { @@ -5411,6 +5670,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 @@ -5439,6 +5713,12 @@ mod tests { fn test_split_100_percent_of_source(feature_set: Arc) { let rent = Rent::default(); let rent_exempt_reserve = rent.minimum_balance(StakeState::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(); @@ -5480,10 +5760,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 @@ -5500,6 +5794,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 { StakeState::Initialized(_) => { @@ -5533,6 +5832,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(StakeState::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(); @@ -5581,10 +5886,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 @@ -5601,6 +5920,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 StakeState::Stake(meta, stake) = state { assert_eq!( @@ -5628,6 +5952,12 @@ mod tests { let rent = Rent::default(); let source_rent_exempt_reserve = rent.minimum_balance(StakeState::size_of() + 100); let split_rent_exempt_reserve = rent.minimum_balance(StakeState::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(); @@ -5673,6 +6003,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), @@ -5698,10 +6037,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), @@ -5711,6 +6070,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), @@ -5754,6 +6117,197 @@ 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(StakeState::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 &[ + (StakeState::Initialized(meta), Ok(())), + (just_stake(meta, delegation_amount), expected_result), + ] { + let source_account = AccountSharedData::new_data_with_space( + source_lamports, + &state, + StakeState::size_of(), + &id(), + ) + .unwrap(); + + let transaction_accounts = + |initial_balance: u64| -> Vec<(Pubkey, AccountSharedData)> { + let destination_account = AccountSharedData::new_data_with_space( + initial_balance, + &StakeState::Uninitialized, + StakeState::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 StakeState::Stake(meta, stake) = state { + // split entire source account, including rent-exempt reserve + if accounts[0].lamports() == 0 { + assert_eq!(Ok(StakeState::Uninitialized), accounts[0].state()); + assert_eq!( + Ok(StakeState::Stake( + *meta, + Stake { + delegation: Delegation { + // delegated amount should not include source + // rent-exempt reserve + stake: delegation_amount, + ..stake.delegation + }, + ..*stake + }, + )), + accounts[1].state() + ); + } else { + assert_eq!( + Ok(StakeState::Stake( + *meta, + Stake { + delegation: Delegation { + stake: minimum_delegation, + ..stake.delegation + }, + ..*stake + }, + )), + accounts[0].state() + ); + assert_eq!( + Ok(StakeState::Stake( + *meta, + Stake { + delegation: Delegation { + stake: split_amount, + ..stake.delegation + }, + ..*stake + }, + )), + 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 a6ee6463a5b8e5..757d2e60a1e97c 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -105,6 +105,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, @@ -709,6 +722,16 @@ pub fn split( StakeState::Stake(meta, mut stake) => { 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, @@ -719,6 +742,7 @@ pub fn split( &meta, Some(&stake), minimum_delegation, + is_active, )?; // split the stake, subtract rent_exempt_balance unless @@ -802,6 +826,7 @@ pub fn split( &meta, None, additional_required_lamports, + false, )?; let mut split_meta = meta; split_meta.rent_exempt_reserve = validated_split_info.destination_rent_exempt_reserve; @@ -964,12 +989,7 @@ pub fn redelegate( let (stake_meta, effective_stake) = if let StakeState::Stake(meta, stake) = 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()); @@ -1234,6 +1254,7 @@ struct ValidatedSplitInfo { /// minimum balance requirements, which is the rent exempt reserve plus the minimum stake /// delegation, and that the source account has enough lamports for the request split amount. If /// not, return an error. +#[allow(clippy::too_many_arguments)] fn validate_split_amount( invoke_context: &InvokeContext, transaction_context: &TransactionContext, @@ -1244,6 +1265,7 @@ fn validate_split_amount( source_meta: &Meta, source_stake: Option<&Stake>, additional_required_lamports: u64, + source_is_active: bool, ) -> Result { let source_account = instruction_context .try_borrow_instruction_account(transaction_context, source_account_index)?; @@ -1285,10 +1307,6 @@ fn validate_split_amount( // nothing to do here } - // 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 destination_rent_exempt_reserve = if invoke_context .feature_set .is_active(&stake_split_uses_rent_sysvar::ID) @@ -1302,6 +1320,25 @@ fn validate_split_amount( destination_data_len as u64, ) }; + + // 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 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 64501af84bc3fb..8bafdfa186c291 100644 --- a/runtime/tests/stake.rs +++ b/runtime/tests/stake.rs @@ -426,15 +426,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); + // 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), @@ -449,7 +455,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 5a342f77f5ef33..5b982432793f26 100644 --- a/sdk/src/feature_set.rs +++ b/sdk/src/feature_set.rs @@ -674,6 +674,10 @@ pub mod revise_turbine_epoch_stakes { solana_sdk::declare_id!("BTWmtJC8U5ZLMbBUUA1k6As62sYjPEjAiNAT55xYGdJU"); } +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 = [ @@ -837,6 +841,7 @@ lazy_static! { (bpf_account_data_direct_mapping::id(), "use memory regions to map account data into the rbpf vm instead of copying the data"), (reduce_stake_warmup_cooldown::id(), "reduce stake warmup cooldown from 25% to 9%"), (revise_turbine_epoch_stakes::id(), "revise turbine epoch stakes"), + (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 e40b29237c344b..924c4e3e8eebb6 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 b1f1522e1558bf..0dd4859f51e948 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 5b2603814b87c5..ac284393e6cd74 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, StakeState}, }, 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(StakeState::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 8df0d4f482e4d5..2fac44edae0965 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 f72278a99f9cca..c97287671dace5 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 00000000000000..2647541b6e402f --- /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::StakeState, +}; + +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(StakeState::size_of())?; + sender_args.rent_exempt_reserve = Some(rent); + } + } + Ok(()) +}