diff --git a/cli/tests/stake.rs b/cli/tests/stake.rs index 481c163c68c40a..2f88acd559dba5 100644 --- a/cli/tests/stake.rs +++ b/cli/tests/stake.rs @@ -2320,3 +2320,757 @@ fn test_stake_minimum_delegation() { let result = process_command(&config); assert!(matches!(result, Ok(..))); } + +#[test] +fn test_stake_redelegation_then_deactivation_withdraw_not_permitted() { + let mint_keypair = Keypair::new(); + let mint_pubkey = mint_keypair.pubkey(); + let authorized_withdrawer = Keypair::new().pubkey(); + let faucet_addr = run_local_faucet(mint_keypair, None); + + // setup test validator + let slots_per_epoch = 32; + let test_validator = TestValidatorGenesis::default() + .fee_rate_governor(FeeRateGovernor::new(0, 0)) + .rent(Rent { + lamports_per_byte_year: 1, + exemption_threshold: 1.0, + ..Rent::default() + }) + .epoch_schedule(EpochSchedule::custom( + slots_per_epoch, + slots_per_epoch, + /* enable_warmup_epochs = */ false, + )) + .faucet_addr(Some(faucet_addr)) + .start_with_mint_address(mint_pubkey, SocketAddrSpace::Unspecified) + .expect("validator start failed"); + + let rpc_client = + RpcClient::new_with_commitment(test_validator.rpc_url(), CommitmentConfig::processed()); + let default_signer = Keypair::new(); + + let mut config = CliConfig::recent_for_tests(); + config.json_rpc_url = test_validator.rpc_url(); + config.signers = vec![&default_signer]; + + request_and_confirm_airdrop( + &rpc_client, + &config, + &config.signers[0].pubkey(), + 100_000_000_000, + ) + .unwrap(); + + // Create vote account + let vote_keypair = Keypair::new(); + config.signers = vec![&default_signer, &vote_keypair]; + config.command = CliCommand::CreateVoteAccount { + vote_account: 1, + seed: None, + identity_account: 0, + authorized_voter: None, + authorized_withdrawer, + commission: 0, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Create second vote account + let vote2_keypair = Keypair::new(); + config.signers = vec![&default_signer, &vote2_keypair]; + config.command = CliCommand::CreateVoteAccount { + vote_account: 1, + seed: None, + identity_account: 0, + authorized_voter: None, + authorized_withdrawer, + commission: 0, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Create stake account + let stake_keypair = Keypair::new(); + config.signers = vec![&default_signer, &stake_keypair]; + config.command = CliCommand::CreateStakeAccount { + stake_account: 1, + seed: None, + staker: None, + withdrawer: None, + withdrawer_signer: None, + lockup: Lockup::default(), + amount: SpendAmount::Some(50_000_000_000), + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + from: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Delegate stake to `vote_keypair` + config.signers = vec![&default_signer]; + config.command = CliCommand::DelegateStake { + stake_account_pubkey: stake_keypair.pubkey(), + vote_account_pubkey: vote_keypair.pubkey(), + stake_authority: 0, + force: true, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + redelegation_stake_account: None, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // wait for new epoch + wait_for_next_epoch_plus_n_slots(&rpc_client, 0); + + // `stake_keypair` should now be delegated to `vote_keypair` and fully activated + let stake_account = rpc_client.get_account(&stake_keypair.pubkey()).unwrap(); + let stake_state: StakeState = stake_account.state().unwrap(); + + let rent_exempt_reserve = match stake_state { + StakeState::Stake(meta, stake, _) => { + assert_eq!(stake.delegation.voter_pubkey, vote_keypair.pubkey()); + meta.rent_exempt_reserve + } + _ => panic!("Unexpected stake state!"), + }; + + assert_eq!( + rpc_client + .get_stake_activation(stake_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Active, + active: 50_000_000_000 - rent_exempt_reserve, + inactive: 0 + } + ); + check_balance!(50_000_000_000, &rpc_client, &stake_keypair.pubkey()); + + // Now try to withdraw from stake_account. It will fail. + let recipient = keypair_from_seed(&[5u8; 32]).unwrap(); + let recipient_pubkey = recipient.pubkey(); + config.command = CliCommand::WithdrawStake { + stake_account_pubkey: stake_keypair.pubkey(), + destination_account_pubkey: recipient_pubkey, + amount: SpendAmount::Some(50_000_000_000), + withdraw_authority: 0, + custodian: None, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + + let withdraw_result = process_command(&config); + if let Err(e) = withdraw_result { + let s = format!("{}", e); + assert!(s.contains("insufficient funds for instruction")); + } else { + unreachable!(); + } + + // Setup `stake2_keypair. Add an extra `rent_exempt_reserve` amount into `stake2_keypair` + // before redelegation to account for the `rent_exempt_reserve` balance that'll be pealed off + // the stake during the redelegation process. + let stake2_keypair = Keypair::new(); + request_and_confirm_airdrop( + &rpc_client, + &config, + &stake2_keypair.pubkey(), + rent_exempt_reserve, + ) + .unwrap(); + + // Redelegate to `vote2_keypair` via `stake2_keypair + config.signers = vec![&default_signer, &stake2_keypair]; + config.command = CliCommand::DelegateStake { + stake_account_pubkey: stake_keypair.pubkey(), + vote_account_pubkey: vote2_keypair.pubkey(), + stake_authority: 0, + force: true, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + redelegation_stake_account: Some(1), + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // `stake_keypair` should now be deactivating + assert_eq!( + rpc_client + .get_stake_activation(stake_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Deactivating, + active: 50_000_000_000 - rent_exempt_reserve, + inactive: 0, + } + ); + + // `stake_keypair2` should now be activating + assert_eq!( + rpc_client + .get_stake_activation(stake2_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Activating, + active: 0, + inactive: 50_000_000_000 - rent_exempt_reserve, + } + ); + + // check that all the stake, save `rent_exempt_reserve`, have been moved from `stake_keypair` + // to `stake2_keypair` + check_balance!(rent_exempt_reserve, &rpc_client, &stake_keypair.pubkey()); + check_balance!(50_000_000_000, &rpc_client, &stake2_keypair.pubkey()); + + // Deactivate stake2_account should fail because stake2_account is not fully activated. + config.signers = vec![&default_signer]; + + config.command = CliCommand::DeactivateStake { + stake_account_pubkey: stake2_keypair.pubkey(), + stake_authority: 0, + sign_only: false, + deactivate_delinquent: false, + dump_transaction_message: true, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + let deactivate_result = process_command(&config); + if let Err(e) = deactivate_result { + let s = format!("{}", e); + assert_eq!( + s, + "redelegated stake must be fully activated before deactivation" + ); + } else { + unreachable!(); + } + + // `stake_keypair2` should still be Activating + assert_eq!( + rpc_client + .get_stake_activation(stake2_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Activating, + active: 0, + inactive: 50_000_000_000 - rent_exempt_reserve, + } + ); + + // Withdraw from stake2 account should still fails + let recipient = keypair_from_seed(&[5u8; 32]).unwrap(); + let recipient_pubkey = recipient.pubkey(); + config.command = CliCommand::WithdrawStake { + stake_account_pubkey: stake2_keypair.pubkey(), + destination_account_pubkey: recipient_pubkey, + amount: SpendAmount::Some(50_000_000_000), + withdraw_authority: 0, + custodian: None, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + + let withdraw_result = process_command(&config); + if let Err(e) = withdraw_result { + let s = format!("{}", e); + assert!(s.contains("insufficient funds for instruction")); + } else { + unreachable!(); + } + + // wait for one epoch, now stake2_account should be fully activated. + wait_for_next_epoch_plus_n_slots(&rpc_client, 0); + + // Deactivate stake2_account should succeed. + config.signers = vec![&default_signer]; + + config.command = CliCommand::DeactivateStake { + stake_account_pubkey: stake2_keypair.pubkey(), + stake_authority: 0, + sign_only: false, + deactivate_delinquent: false, + dump_transaction_message: true, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + let deactivate_result = process_command(&config); + assert!(deactivate_result.is_ok()); +} + +fn test_stake_redelegation_then_merge_deactivation_not_permitted(deactivating_merge_stake: bool) { + let mint_keypair = Keypair::new(); + let mint_pubkey = mint_keypair.pubkey(); + let authorized_withdrawer = Keypair::new().pubkey(); + let faucet_addr = run_local_faucet(mint_keypair, None); + + // setup test validator + let slots_per_epoch = 32; + let test_validator = TestValidatorGenesis::default() + .fee_rate_governor(FeeRateGovernor::new(0, 0)) + .rent(Rent { + lamports_per_byte_year: 1, + exemption_threshold: 1.0, + ..Rent::default() + }) + .epoch_schedule(EpochSchedule::custom( + slots_per_epoch, + slots_per_epoch, + /* enable_warmup_epochs = */ false, + )) + .faucet_addr(Some(faucet_addr)) + .start_with_mint_address(mint_pubkey, SocketAddrSpace::Unspecified) + .expect("validator start failed"); + + let rpc_client = + RpcClient::new_with_commitment(test_validator.rpc_url(), CommitmentConfig::processed()); + let default_signer = Keypair::new(); + + let mut config = CliConfig::recent_for_tests(); + config.json_rpc_url = test_validator.rpc_url(); + config.signers = vec![&default_signer]; + + request_and_confirm_airdrop( + &rpc_client, + &config, + &config.signers[0].pubkey(), + 1_000_000_000_000, + ) + .unwrap(); + + // Create vote account + let vote_keypair = Keypair::new(); + config.signers = vec![&default_signer, &vote_keypair]; + config.command = CliCommand::CreateVoteAccount { + vote_account: 1, + seed: None, + identity_account: 0, + authorized_voter: None, + authorized_withdrawer, + commission: 0, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Create second vote account + let vote2_keypair = Keypair::new(); + config.signers = vec![&default_signer, &vote2_keypair]; + config.command = CliCommand::CreateVoteAccount { + vote_account: 1, + seed: None, + identity_account: 0, + authorized_voter: None, + authorized_withdrawer, + commission: 0, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Create stake account + let stake_keypair = Keypair::new(); + config.signers = vec![&default_signer, &stake_keypair]; + config.command = CliCommand::CreateStakeAccount { + stake_account: 1, + seed: None, + staker: None, + withdrawer: None, + withdrawer_signer: None, + lockup: Lockup::default(), + amount: SpendAmount::Some(50_000_000_000), + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + from: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Delegate stake to `vote_keypair` + config.signers = vec![&default_signer]; + config.command = CliCommand::DelegateStake { + stake_account_pubkey: stake_keypair.pubkey(), + vote_account_pubkey: vote_keypair.pubkey(), + stake_authority: 0, + force: true, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + redelegation_stake_account: None, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // wait for new epoch + wait_for_next_epoch_plus_n_slots(&rpc_client, 0); + + // `stake_keypair` should now be delegated to `vote_keypair` and fully activated + let stake_account = rpc_client.get_account(&stake_keypair.pubkey()).unwrap(); + let stake_state: StakeState = stake_account.state().unwrap(); + + let rent_exempt_reserve = match stake_state { + StakeState::Stake(meta, stake, _) => { + assert_eq!(stake.delegation.voter_pubkey, vote_keypair.pubkey()); + meta.rent_exempt_reserve + } + _ => panic!("Unexpected stake state!"), + }; + + assert_eq!( + rpc_client + .get_stake_activation(stake_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Active, + active: 50_000_000_000 - rent_exempt_reserve, + inactive: 0 + } + ); + check_balance!(50_000_000_000, &rpc_client, &stake_keypair.pubkey()); + + // Now try to withdraw from stake_account. It will fail. + let recipient = keypair_from_seed(&[5u8; 32]).unwrap(); + let recipient_pubkey = recipient.pubkey(); + config.command = CliCommand::WithdrawStake { + stake_account_pubkey: stake_keypair.pubkey(), + destination_account_pubkey: recipient_pubkey, + amount: SpendAmount::Some(50_000_000_000), + withdraw_authority: 0, + custodian: None, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + + let withdraw_result = process_command(&config); + if let Err(e) = withdraw_result { + let s = format!("{}", e); + assert!(s.contains("insufficient funds for instruction")); + } else { + unreachable!(); + } + + // Setup `stake2_keypair. Add an extra `rent_exempt_reserve` amount into `stake2_keypair` + // before redelegation to account for the `rent_exempt_reserve` balance that'll be pealed off + // the stake during the redelegation process. + let stake2_keypair = Keypair::new(); + request_and_confirm_airdrop( + &rpc_client, + &config, + &stake2_keypair.pubkey(), + rent_exempt_reserve, + ) + .unwrap(); + + // Redelegate to `vote2_keypair` via `stake2_keypair + config.signers = vec![&default_signer, &stake2_keypair]; + config.command = CliCommand::DelegateStake { + stake_account_pubkey: stake_keypair.pubkey(), + vote_account_pubkey: vote2_keypair.pubkey(), + stake_authority: 0, + force: true, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + redelegation_stake_account: Some(1), + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // `stake_keypair` should now be deactivating + assert_eq!( + rpc_client + .get_stake_activation(stake_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Deactivating, + active: 50_000_000_000 - rent_exempt_reserve, + inactive: 0, + } + ); + + // `stake_keypair2` should now be activating + assert_eq!( + rpc_client + .get_stake_activation(stake2_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Activating, + active: 0, + inactive: 50_000_000_000 - rent_exempt_reserve, + } + ); + + // check that all the stake, save `rent_exempt_reserve`, have been moved from `stake_keypair` + // to `stake2_keypair` + check_balance!(rent_exempt_reserve, &rpc_client, &stake_keypair.pubkey()); + check_balance!(50_000_000_000, &rpc_client, &stake2_keypair.pubkey()); + + // Create stake merge account + let stake_merge_keypair = Keypair::new(); + config.signers = vec![&default_signer, &stake_merge_keypair]; + config.command = CliCommand::CreateStakeAccount { + stake_account: 1, + seed: None, + staker: None, + withdrawer: None, + withdrawer_signer: None, + lockup: Lockup::default(), + amount: SpendAmount::Some(50_000_000_000), + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + from: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Delegate stake_merge to `vote_keypair2` + config.signers = vec![&default_signer]; + config.command = CliCommand::DelegateStake { + stake_account_pubkey: stake_merge_keypair.pubkey(), + vote_account_pubkey: vote2_keypair.pubkey(), + stake_authority: 0, + force: true, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + redelegation_stake_account: None, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Deactivate stake_merge_account to test (Activating, Inactive) merge case + if deactivating_merge_stake { + config.signers = vec![&default_signer]; + + config.command = CliCommand::DeactivateStake { + stake_account_pubkey: stake_merge_keypair.pubkey(), + stake_authority: 0, + sign_only: false, + deactivate_delinquent: false, + dump_transaction_message: true, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + } + + // merge stake_merge account to stake2_account should NOT clear the the `MustFullyActivateBeforeDeactivationIsPermitted` flag. + config.signers = vec![&default_signer]; + + config.command = CliCommand::MergeStake { + stake_account_pubkey: stake2_keypair.pubkey(), + source_stake_account_pubkey: stake_merge_keypair.pubkey(), + stake_authority: 0, + sign_only: false, + dump_transaction_message: true, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + fee_payer: 0, + compute_unit_price: None, + }; + process_command(&config).unwrap(); + + // Deactivate stake2_account should fail because stake2_account is not fully activated. + config.signers = vec![&default_signer]; + + config.command = CliCommand::DeactivateStake { + stake_account_pubkey: stake2_keypair.pubkey(), + stake_authority: 0, + sign_only: false, + deactivate_delinquent: false, + dump_transaction_message: true, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + let deactivate_result = process_command(&config); + if let Err(e) = deactivate_result { + let s = format!("{}", e); + assert_eq!( + s, + "redelegated stake must be fully activated before deactivation" + ); + } else { + unreachable!(); + } + + // `stake_keypair2` should still be Activating + assert_eq!( + rpc_client + .get_stake_activation(stake2_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Activating, + active: 0, + inactive: 100_000_000_000 - rent_exempt_reserve, + } + ); + + // Withdraw from stake2 account should still fails + let recipient = keypair_from_seed(&[5u8; 32]).unwrap(); + let recipient_pubkey = recipient.pubkey(); + config.command = CliCommand::WithdrawStake { + stake_account_pubkey: stake2_keypair.pubkey(), + destination_account_pubkey: recipient_pubkey, + amount: SpendAmount::Some(50_000_000_000), + withdraw_authority: 0, + custodian: None, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + + let withdraw_result = process_command(&config); + if let Err(e) = withdraw_result { + let s = format!("{}", e); + assert!(s.contains("insufficient funds for instruction")); + } else { + unreachable!(); + } + + // wait for one epoch, now stake2_account should be fully activated. + wait_for_next_epoch_plus_n_slots(&rpc_client, 0); + + // Deactivate stake2_account should succeed. + config.signers = vec![&default_signer]; + + config.command = CliCommand::DeactivateStake { + stake_account_pubkey: stake2_keypair.pubkey(), + stake_authority: 0, + sign_only: false, + deactivate_delinquent: false, + dump_transaction_message: true, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + compute_unit_price: None, + }; + let deactivate_result = process_command(&config); + assert!(deactivate_result.is_ok()); +} + +#[test] +fn test_stake_redelegation_then_merge_activating_stake_then_deactivation_not_permitted() { + test_stake_redelegation_then_merge_deactivation_not_permitted(false); +} + +#[test] +fn test_stake_redelegation_then_merge_inactive_stake_then_deactivation_not_permitted() { + test_stake_redelegation_then_merge_deactivation_not_permitted(true); +} diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index 743b7d8d0f5e6b..3ff4b31dfc1ef0 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -261,7 +261,7 @@ declare_process_instruction!( let mut me = get_stake_account()?; let clock = get_sysvar_with_account_check::clock(invoke_context, instruction_context, 1)?; - deactivate(&mut me, &clock, &signers) + deactivate(invoke_context, &mut me, &clock, &signers) } Ok(StakeInstruction::SetLockup(lockup)) => { let mut me = get_stake_account()?; @@ -407,6 +407,7 @@ declare_process_instruction!( let clock = invoke_context.get_sysvar_cache().get_clock()?; deactivate_delinquent( + invoke_context, transaction_context, instruction_context, &mut me, @@ -6458,6 +6459,10 @@ mod tests { ..Clock::default() }), ), + ( + stake_history::id(), + create_account_shared_data_for_test(&StakeHistory::default()), + ), ], vec![ AccountMeta { @@ -7181,10 +7186,10 @@ mod tests { let mut deactivating_stake_account = prepare_stake_account(0 /*activation_epoch:*/, None); - if let StakeState::Stake(meta, mut stake, _stake_flags) = + if let StakeState::Stake(meta, mut stake, stake_flags) = deactivating_stake_account.deserialize_data().unwrap() { - stake.deactivate(current_epoch).unwrap(); + stake.deactivate(current_epoch, stake_flags, None).unwrap(); assert_eq!( StakeActivationStatus { effective: minimum_delegation + rent_exempt_reserve, diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index 448a4d811f1b56..95b8da2d1e4a5b 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -614,14 +614,18 @@ pub fn delegate( } pub fn deactivate( + invoke_context: &InvokeContext, stake_account: &mut BorrowedAccount, clock: &Clock, signers: &HashSet, ) -> Result<(), InstructionError> { - if let StakeState::Stake(meta, mut stake, stake_flags) = stake_account.get_state()? { + if let StakeState::Stake(meta, mut stake, mut stake_flags) = stake_account.get_state()? { meta.authorized.check(signers, StakeAuthorize::Staker)?; - stake.deactivate(clock.epoch)?; - + let stake_history = invoke_context.get_sysvar_cache().get_stake_history()?; + stake.deactivate(clock.epoch, stake_flags, Some(&stake_history))?; + // After deactivation, need to clear DeactivationFlag. + // So that future activation and deactivation are not subject to the `MustFullyActivateBeforeDeactivationIsPermitted` restriction. + stake_flags.remove(StakeFlags::MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED); stake_account.set_state(&StakeState::Stake(meta, stake, stake_flags)) } else { Err(InstructionError::InvalidAccountData) @@ -957,7 +961,7 @@ pub fn redelegate( // deactivate `stake_account` // // Note: This function also ensures `signers` contains the `StakeAuthorize::Staker` - deactivate(stake_account, &clock, signers)?; + deactivate(invoke_context, stake_account, &clock, signers)?; // transfer the effective stake to the uninitialized stake account stake_account.checked_sub_lamports(effective_stake)?; @@ -984,7 +988,7 @@ pub fn redelegate( clock.epoch, config, ), - StakeFlags::empty(), + StakeFlags::MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, ))?; Ok(()) @@ -1095,6 +1099,7 @@ pub fn withdraw( } pub(crate) fn deactivate_delinquent( + invoke_context: &InvokeContext, transaction_context: &TransactionContext, instruction_context: &InstructionContext, stake_account: &mut BorrowedAccount, @@ -1128,7 +1133,7 @@ pub(crate) fn deactivate_delinquent( return Err(StakeError::InsufficientReferenceVotes.into()); } - if let StakeState::Stake(meta, mut stake, stake_flags) = stake_account.get_state()? { + if let StakeState::Stake(meta, mut stake, mut stake_flags) = stake_account.get_state()? { if stake.delegation.voter_pubkey != *delinquent_vote_account_pubkey { return Err(StakeError::VoteAddressMismatch.into()); } @@ -1136,7 +1141,10 @@ pub(crate) fn deactivate_delinquent( // Deactivate the stake account if its delegated vote account has never voted or has not // voted in the last `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION` if eligible_for_deactivate_delinquent(&delinquent_vote_state.epoch_credits, current_epoch) { - stake.deactivate(current_epoch)?; + let stake_history = invoke_context.get_sysvar_cache().get_stake_history()?; + stake.deactivate(current_epoch, stake_flags, Some(&stake_history))?; + // After deactivation, need to clear DeactivationFlag to Empty. + stake_flags.remove(StakeFlags::MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED); stake_account.set_state(&StakeState::Stake(meta, stake, stake_flags)) } else { Err(StakeError::MinimumDelinquentEpochsForDeactivationNotMet.into()) diff --git a/sdk/program/src/stake/instruction.rs b/sdk/program/src/stake/instruction.rs index d3b703f5add4ff..54909cca81dcc0 100644 --- a/sdk/program/src/stake/instruction.rs +++ b/sdk/program/src/stake/instruction.rs @@ -67,7 +67,6 @@ pub enum StakeError { #[error("stake redelegation to the same vote account is not permitted")] RedelegateToSameVoteAccount, - #[allow(dead_code)] #[error("redelegated stake must be fully activated before deactivation")] RedelegatedStakeMustFullyActivateBeforeDeactivationIsPermitted, } diff --git a/sdk/program/src/stake/state.rs b/sdk/program/src/stake/state.rs index 3dbd34efab3f16..acfb0d9bb30548 100644 --- a/sdk/program/src/stake/state.rs +++ b/sdk/program/src/stake/state.rs @@ -567,13 +567,31 @@ impl Stake { Ok(new) } - pub fn deactivate(&mut self, epoch: Epoch) -> Result<(), StakeError> { + pub fn deactivate( + &mut self, + epoch: Epoch, + stake_flags: StakeFlags, + history: Option<&StakeHistory>, + ) -> Result<(), StakeError> { if self.delegation.deactivation_epoch != std::u64::MAX { - Err(StakeError::AlreadyDeactivated) - } else { - self.delegation.deactivation_epoch = epoch; - Ok(()) + return Err(StakeError::AlreadyDeactivated); } + + // when MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED flag is set on stake_flags, + // deactivation is only permitted when the stake delegation activating amount is zero. + if stake_flags.contains(StakeFlags::MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED) { + let status = self + .delegation + .stake_activating_and_deactivating(epoch, history); + if status.activating != 0 { + return Err( + StakeError::RedelegatedStakeMustFullyActivateBeforeDeactivationIsPermitted, + ); + } + } + + self.delegation.deactivation_epoch = epoch; + Ok(()) } } @@ -695,4 +713,43 @@ mod test { }) ); } + + #[test] + fn stake_flag_member_offset() { + const FLAG_OFFSET: usize = 196; + let check_flag = |flag, expected| { + let stake = StakeState::Stake( + Meta { + rent_exempt_reserve: 1, + authorized: Authorized { + staker: Pubkey::new_unique(), + withdrawer: Pubkey::new_unique(), + }, + lockup: Lockup::default(), + }, + Stake { + delegation: Delegation { + voter_pubkey: Pubkey::new_unique(), + stake: u64::MAX, + activation_epoch: Epoch::MAX, + deactivation_epoch: Epoch::MAX, + warmup_cooldown_rate: f64::MAX, + }, + credits_observed: 1, + }, + flag, + ); + + let bincode_serialized = serialize(&stake).unwrap(); + let borsh_serialized = StakeState::try_to_vec(&stake).unwrap(); + + assert_eq!(bincode_serialized[FLAG_OFFSET], expected); + assert_eq!(borsh_serialized[FLAG_OFFSET], expected); + }; + check_flag( + StakeFlags::MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, + 1, + ); + check_flag(StakeFlags::empty(), 0); + } }