diff --git a/cli/src/cli.rs b/cli/src/cli.rs index d4e580d7ed8416..d37d2ef87c110e 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -214,6 +214,7 @@ pub enum CliCommand { nonce_authority: SignerIndex, memo: Option, fee_payer: SignerIndex, + redelegation_stake_account_pubkey: Option, }, SplitStake { stake_account_pubkey: Pubkey, @@ -683,6 +684,9 @@ pub fn parse_command( ("delegate-stake", Some(matches)) => { parse_stake_delegate_stake(matches, default_signer, wallet_manager) } + ("redelegate-stake", Some(matches)) => { + parse_stake_delegate_stake(matches, default_signer, wallet_manager) + } ("withdraw-stake", Some(matches)) => { parse_stake_withdraw_stake(matches, default_signer, wallet_manager) } @@ -1136,6 +1140,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { nonce_authority, memo, fee_payer, + redelegation_stake_account_pubkey, } => process_delegate_stake( &rpc_client, config, @@ -1150,6 +1155,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { *nonce_authority, memo.as_ref(), *fee_payer, + redelegation_stake_account_pubkey.as_ref(), ), CliCommand::SplitStake { stake_account_pubkey, diff --git a/cli/src/stake.rs b/cli/src/stake.rs index 769bfd94fb3da9..2ab885d35b596a 100644 --- a/cli/src/stake.rs +++ b/cli/src/stake.rs @@ -285,6 +285,51 @@ impl StakeSubCommands for App<'_, '_> { .arg(fee_payer_arg()) .arg(memo_arg()) ) + .subcommand( + SubCommand::with_name("redelegate-stake") + .about("Redelegate active stake to another vote account") + .arg( + Arg::with_name("force") + .long("force") + .takes_value(false) + .hidden(true) // Don't document this argument to discourage its use + .help("Override vote account sanity checks (use carefully!)") + ) + .arg( + pubkey!(Arg::with_name("stake_account_pubkey") + .index(1) + .value_name("STAKE_ACCOUNT_ADDRESS") + .required(true), + "Existing delegated stake account that has been fully activated. \ + On success this stake account will be scheduled for deactivation and the rent-exempt balance \ + may be withdrawn once fully deactivated") + ) + .arg( + pubkey!(Arg::with_name("vote_account_pubkey") + .index(2) + .value_name("REDELEGATED_VOTE_ACCOUNT_ADDRESS") + .required(true), + "The vote account to which the stake will be redelegated") + ) + .arg( + Arg::with_name("redelegation_stake_account") + .index(3) + .value_name("REDELEGATION_STAKE_ACCOUNT") + .takes_value(true) + .required(true) + .validator(is_valid_signer) + .help("Stake account to create for the redelegation. \ + On success this stake account will be created and scheduled for activation with all \ + the stake in the existing stake account, exclusive of the rent-exempt balance retained \ + in the existing account") + ) + .arg(stake_authority_arg()) + .offline_args() + .nonce_args(false) + .arg(fee_payer_arg()) + .arg(memo_arg()) + ) + .subcommand( SubCommand::with_name("stake-authorize") .about("Authorize a new signing keypair for the given stake account") @@ -753,6 +798,8 @@ pub fn parse_stake_delegate_stake( pubkey_of_signer(matches, "stake_account_pubkey", wallet_manager)?.unwrap(); let vote_account_pubkey = pubkey_of_signer(matches, "vote_account_pubkey", wallet_manager)?.unwrap(); + let (redelegation_stake_account, redelegation_stake_account_pubkey) = + signer_of(matches, "redelegation_stake_account", wallet_manager)?; let force = matches.is_present("force"); let sign_only = matches.is_present(SIGN_ONLY_ARG.name); let dump_transaction_message = matches.is_present(DUMP_TRANSACTION_MESSAGE.name); @@ -765,7 +812,7 @@ pub fn parse_stake_delegate_stake( signer_of(matches, NONCE_AUTHORITY_ARG.name, wallet_manager)?; let (fee_payer, fee_payer_pubkey) = signer_of(matches, FEE_PAYER_ARG.name, wallet_manager)?; - let mut bulk_signers = vec![stake_authority, fee_payer]; + let mut bulk_signers = vec![stake_authority, fee_payer, redelegation_stake_account]; if nonce_account.is_some() { bulk_signers.push(nonce_authority); } @@ -785,6 +832,7 @@ pub fn parse_stake_delegate_stake( nonce_authority: signer_info.index_of(nonce_authority_pubkey).unwrap(), memo, fee_payer: signer_info.index_of(fee_payer_pubkey).unwrap(), + redelegation_stake_account_pubkey, }, signers: signer_info.signers, }) @@ -2414,11 +2462,28 @@ pub fn process_delegate_stake( nonce_authority: SignerIndex, memo: Option<&String>, fee_payer: SignerIndex, + redelegation_stake_account_pubkey: Option<&Pubkey>, ) -> ProcessResult { check_unique_pubkeys( (&config.signers[0].pubkey(), "cli keypair".to_string()), (stake_account_pubkey, "stake_account_pubkey".to_string()), )?; + if let Some(redelegation_stake_account_pubkey) = &redelegation_stake_account_pubkey { + check_unique_pubkeys( + (stake_account_pubkey, "stake_account_pubkey".to_string()), + ( + redelegation_stake_account_pubkey, + "redelegation_stake_account".to_string(), + ), + )?; + check_unique_pubkeys( + (&config.signers[0].pubkey(), "cli keypair".to_string()), + ( + redelegation_stake_account_pubkey, + "redelegation_stake_account".to_string(), + ), + )?; + } let stake_authority = config.signers[stake_authority]; if !sign_only { @@ -2471,12 +2536,22 @@ pub fn process_delegate_stake( let recent_blockhash = blockhash_query.get_blockhash(rpc_client, config.commitment)?; - let ixs = vec![stake_instruction::delegate_stake( - stake_account_pubkey, - &stake_authority.pubkey(), - vote_account_pubkey, - )] + let ixs = if let Some(redelegation_stake_account_pubkey) = &redelegation_stake_account_pubkey { + stake_instruction::redelegate( + stake_account_pubkey, + &stake_authority.pubkey(), + vote_account_pubkey, + redelegation_stake_account_pubkey, + ) + } else { + vec![stake_instruction::delegate_stake( + stake_account_pubkey, + &stake_authority.pubkey(), + vote_account_pubkey, + )] + } .with_memo(memo); + let nonce_authority = config.signers[nonce_authority]; let fee_payer = config.signers[fee_payer]; @@ -3867,6 +3942,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }, signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], } @@ -3898,6 +3974,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -3931,6 +4008,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }, signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], } @@ -3965,6 +4043,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }, signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], } @@ -3994,6 +4073,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }, signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], } @@ -4033,6 +4113,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 1, + redelegation_stake_account_pubkey: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -4081,6 +4162,7 @@ mod tests { nonce_authority: 2, memo: None, fee_payer: 1, + redelegation_stake_account_pubkey: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -4117,6 +4199,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 1, + redelegation_stake_account_pubkey: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -4125,6 +4208,51 @@ mod tests { } ); + // Test RedelegateStake Subcommand (minimal test due to the significant implementation + // overlap with DelegateStake) + let (redelegation_stake_account_keypair_file, mut redelegation_stake_account_tmp_file) = + make_tmp_file(); + let redelegation_stake_account_keypair = Keypair::new(); + write_keypair( + &redelegation_stake_account_keypair, + redelegation_stake_account_tmp_file.as_file_mut(), + ) + .unwrap(); + let redelegation_stake_account_pubkey = redelegation_stake_account_keypair.pubkey(); + + let test_redelegate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "redelegate-stake", + &stake_account_string, + &vote_account_string, + &redelegation_stake_account_keypair_file, + ]); + assert_eq!( + parse_command(&test_redelegate_stake, &default_signer, &mut None).unwrap(), + CliCommandInfo { + command: CliCommand::DelegateStake { + stake_account_pubkey, + vote_account_pubkey, + stake_authority: 0, + force: false, + 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_pubkey: Some(redelegation_stake_account_pubkey), + }, + signers: vec![ + read_keypair_file(&default_keypair_file).unwrap().into(), + read_keypair_file(&redelegation_stake_account_keypair_file) + .unwrap() + .into() + ], + } + ); + // Test WithdrawStake Subcommand let test_withdraw_stake = test_commands.clone().get_matches_from(vec![ "test", diff --git a/cli/src/test_utils.rs b/cli/src/test_utils.rs index 082f92d789c3ff..287ebce27746c9 100644 --- a/cli/src/test_utils.rs +++ b/cli/src/test_utils.rs @@ -1,6 +1,9 @@ use { solana_client::rpc_client::RpcClient, - solana_sdk::{clock::DEFAULT_MS_PER_SLOT, commitment_config::CommitmentConfig}, + solana_sdk::{ + clock::{Epoch, DEFAULT_MS_PER_SLOT}, + commitment_config::CommitmentConfig, + }, std::{thread::sleep, time::Duration}, }; @@ -35,3 +38,16 @@ pub fn check_ready(rpc_client: &RpcClient) { sleep(Duration::from_millis(DEFAULT_MS_PER_SLOT)); } } + +pub fn wait_for_next_epoch(rpc_client: &RpcClient) -> Epoch { + let current_epoch = rpc_client.get_epoch_info().unwrap().epoch; + println!("waiting for epoch {}", current_epoch + 1); + loop { + sleep(Duration::from_millis(DEFAULT_MS_PER_SLOT)); + + let next_epoch = rpc_client.get_epoch_info().unwrap().epoch; + if next_epoch > current_epoch { + return next_epoch; + } + } +} diff --git a/cli/tests/stake.rs b/cli/tests/stake.rs index 2fda3af9b15651..e3e6bde9ccbcd4 100644 --- a/cli/tests/stake.rs +++ b/cli/tests/stake.rs @@ -6,21 +6,25 @@ use { cli::{process_command, request_and_confirm_airdrop, CliCommand, CliConfig}, spend_utils::SpendAmount, stake::StakeAuthorizationIndexed, - test_utils::check_ready, + test_utils::{check_ready, wait_for_next_epoch}, }, solana_cli_output::{parse_sign_only_reply_string, OutputFormat}, solana_client::{ blockhash_query::{self, BlockhashQuery}, nonce_utils, rpc_client::RpcClient, + rpc_response::{RpcStakeActivation, StakeActivationState}, }, solana_faucet::faucet::run_local_faucet, solana_sdk::{ account_utils::StateMut, commitment_config::CommitmentConfig, + epoch_schedule::EpochSchedule, fee::FeeStructure, + fee_calculator::FeeRateGovernor, nonce::State as NonceState, pubkey::Pubkey, + rent::Rent, signature::{keypair_from_seed, Keypair, Signer}, stake::{ self, @@ -29,9 +33,259 @@ use { }, }, solana_streamer::socket::SocketAddrSpace, - solana_test_validator::TestValidator, + solana_test_validator::{TestValidator, TestValidatorGenesis}, }; +#[test] +fn test_stake_redelegation() { + 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); + + 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, + }; + 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, + }; + 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, + }; + 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_pubkey: None, + }; + process_command(&config).unwrap(); + + // wait for new epoch + wait_for_next_epoch(&rpc_client); + + // `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()); + + let stake2_keypair = Keypair::new(); + + // 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 + request_and_confirm_airdrop( + &rpc_client, + &config, + &stake2_keypair.pubkey(), + rent_exempt_reserve, + ) + .unwrap(); + + // wait for a new epoch to ensure the `Redelegate` happens as soon as possible in the epoch + // to reduce the risk of a race condition when checking the stake account correctly enters the + // deactivating state for the remainder of the current epoch + wait_for_next_epoch(&rpc_client); + + // 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_pubkey: Some(stake2_keypair.pubkey()), + }; + 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()); + + // wait for new epoch + wait_for_next_epoch(&rpc_client); + + // `stake_keypair` should now be deactivated + assert_eq!( + rpc_client + .get_stake_activation(stake_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Inactive, + active: 0, + inactive: 0, + } + ); + + // `stake2_keypair` should now be delegated to `vote2_keypair` and fully activated + let stake2_account = rpc_client.get_account(&stake2_keypair.pubkey()).unwrap(); + let stake2_state: StakeState = stake2_account.state().unwrap(); + + match stake2_state { + StakeState::Stake(_meta, stake) => { + assert_eq!(stake.delegation.voter_pubkey, vote2_keypair.pubkey()); + } + _ => panic!("Unexpected stake2 state!"), + }; + + assert_eq!( + rpc_client + .get_stake_activation(stake2_keypair.pubkey(), None) + .unwrap(), + RpcStakeActivation { + state: StakeActivationState::Active, + active: 50_000_000_000 - rent_exempt_reserve, + inactive: 0 + } + ); +} + #[test] fn test_stake_delegation_force() { let mint_keypair = Keypair::new(); @@ -113,6 +367,7 @@ fn test_stake_delegation_force() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }; process_command(&config).unwrap_err(); @@ -129,6 +384,7 @@ fn test_stake_delegation_force() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }; process_command(&config).unwrap(); } @@ -205,6 +461,7 @@ fn test_seed_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }; process_command(&config_validator).unwrap(); @@ -293,6 +550,7 @@ fn test_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }; process_command(&config_validator).unwrap(); @@ -405,6 +663,7 @@ fn test_offline_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }; config_offline.output_format = OutputFormat::JsonCompact; let sig_response = process_command(&config_offline).unwrap(); @@ -426,6 +685,7 @@ fn test_offline_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }; process_command(&config_payer).unwrap(); @@ -558,6 +818,7 @@ fn test_nonced_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegation_stake_account_pubkey: None, }; process_command(&config).unwrap(); diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index 3d10506212a2f9..a0de27299a14fc 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -3,7 +3,7 @@ use { config, stake_state::{ authorize, authorize_with_seed, deactivate, deactivate_delinquent, delegate, - initialize, merge, set_lockup, split, withdraw, + initialize, merge, redelegate, set_lockup, split, withdraw, }, }, log::*, @@ -177,6 +177,7 @@ pub fn process_instruction( let config = config::from(&config_account).ok_or(InstructionError::InvalidArgument)?; drop(config_account); delegate( + invoke_context, transaction_context, instruction_context, 0, @@ -424,6 +425,36 @@ pub fn process_instruction( Err(InstructionError::InvalidInstructionData) } } + Ok(StakeInstruction::Redelegate) => { + let mut me = get_stake_account()?; + if invoke_context + .feature_set + .is_active(&feature_set::stake_redelegate_instruction::id()) + { + instruction_context.check_number_of_instruction_accounts(3)?; + let config_account = + instruction_context.try_borrow_instruction_account(transaction_context, 3)?; + if !config::check_id(config_account.get_key()) { + return Err(InstructionError::InvalidArgument); + } + let config = + config::from(&config_account).ok_or(InstructionError::InvalidArgument)?; + drop(config_account); + + redelegate( + invoke_context, + transaction_context, + instruction_context, + &mut me, + 1, + 2, + &config, + &signers, + ) + } else { + Err(InstructionError::InvalidInstructionData) + } + } Err(err) => { if !invoke_context.feature_set.is_active( &feature_set::add_get_minimum_delegation_instruction_to_stake_program::id(), @@ -463,14 +494,19 @@ mod tests { set_lockup_checked, AuthorizeCheckedWithSeedArgs, AuthorizeWithSeedArgs, LockupArgs, StakeError, }, - state::{Authorized, Lockup, StakeAuthorize}, + state::{Authorized, Lockup, StakeActivationStatus, StakeAuthorize}, MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION, }, stake_history::{StakeHistory, StakeHistoryEntry}, system_program, sysvar, }, solana_vote_program::vote_state::{self, VoteState, VoteStateVersions}, - std::{borrow::BorrowMut, collections::HashSet, str::FromStr, sync::Arc}, + std::{ + borrow::{Borrow, BorrowMut}, + collections::HashSet, + str::FromStr, + sync::Arc, + }, test_case::test_case, }; @@ -853,6 +889,16 @@ mod tests { ), Err(InstructionError::InvalidAccountOwner), ); + process_instruction_as_one_arg( + &feature_set, + &instruction::redelegate( + &spoofed_stake_state_pubkey(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + )[2], + Err(InstructionError::InvalidAccountOwner), + ); } #[test_case(feature_set_old_behavior(); "old_behavior")] @@ -6823,4 +6869,609 @@ mod tests { Err(StakeError::MinimumDelinquentEpochsForDeactivationNotMet.into()), ); } + + #[test_case(feature_set_old_behavior(); "old_behavior")] + #[test_case(feature_set_new_behavior(); "new_behavior")] + fn test_redelegate(feature_set: FeatureSet) { + let feature_set = Arc::new(feature_set); + + 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 mut sysvar_cache_override = SysvarCache::default(); + sysvar_cache_override.set_stake_history(stake_history.clone()); + sysvar_cache_override.set_rent(rent); + sysvar_cache_override.set_clock(Clock { + epoch: current_epoch, + ..Clock::default() + }); + + let authorized_staker = Pubkey::new_unique(); + let vote_address = Pubkey::new_unique(); + let new_vote_address = Pubkey::new_unique(); + let stake_address = Pubkey::new_unique(); + let uninitialized_stake_address = Pubkey::new_unique(); + + let prepare_stake_account = |activation_epoch, expected_stake_activation_status| { + let initial_stake_delegation = minimum_delegation + rent_exempt_reserve; + let initial_stake_state = StakeState::Stake( + Meta { + authorized: Authorized { + staker: authorized_staker, + withdrawer: Pubkey::new_unique(), + }, + rent_exempt_reserve, + ..Meta::default() + }, + new_stake( + initial_stake_delegation, + &vote_address, + &VoteState::default(), + activation_epoch, + &stake_config::Config::default(), + ), + ); + + if let Some(expected_stake_activation_status) = expected_stake_activation_status { + assert_eq!( + expected_stake_activation_status, + initial_stake_state + .delegation() + .unwrap() + .stake_activating_and_deactivating(current_epoch, Some(&stake_history)) + ); + } + + AccountSharedData::new_data_with_space( + rent_exempt_reserve + initial_stake_delegation, /* lamports */ + &initial_stake_state, + StakeState::size_of(), + &id(), + ) + .unwrap() + }; + + let new_vote_account = AccountSharedData::new_data_with_space( + 1, /* lamports */ + &VoteStateVersions::new_current(VoteState::default()), + VoteState::size_of(), + &solana_vote_program::id(), + ) + .unwrap(); + + let process_instruction_redelegate = + |stake_address: &Pubkey, + stake_account: &AccountSharedData, + authorized_staker: &Pubkey, + vote_address: &Pubkey, + vote_account: &AccountSharedData, + uninitialized_stake_address: &Pubkey, + uninitialized_stake_account: &AccountSharedData, + expected_result| { + process_instruction_with_overrides( + &serialize(&StakeInstruction::Redelegate).unwrap(), + vec![ + (*stake_address, stake_account.clone()), + ( + *uninitialized_stake_address, + uninitialized_stake_account.clone(), + ), + (*vote_address, vote_account.clone()), + ( + stake_config::id(), + config::create_account(0, &stake_config::Config::default()), + ), + (*authorized_staker, AccountSharedData::default()), + ], + vec![ + AccountMeta { + pubkey: *stake_address, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: *uninitialized_stake_address, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: *vote_address, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: stake_config::id(), + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: *authorized_staker, + is_signer: true, + is_writable: false, + }, + ], + Some(&sysvar_cache_override), + Some(Arc::clone(&feature_set)), + expected_result, + ) + }; + + // + // Failure: incorrect authorized staker + // + let stake_account = prepare_stake_account(0 /*activation_epoch*/, None); + let uninitialized_stake_account = + AccountSharedData::new(0 /* lamports */, StakeState::size_of(), &id()); + + let _ = process_instruction_redelegate( + &stake_address, + &stake_account, + &Pubkey::new_unique(), // <-- Incorrect authorized staker + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Err(InstructionError::MissingRequiredSignature), + ); + + // + // Success: normal case + // + let output_accounts = process_instruction_redelegate( + &stake_address, + &stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Ok(()), + ); + + assert_eq!(output_accounts[0].lamports(), rent_exempt_reserve); + if let StakeState::Stake(meta, stake) = + output_accounts[0].borrow().deserialize_data().unwrap() + { + assert_eq!(meta.rent_exempt_reserve, rent_exempt_reserve); + assert_eq!( + stake.delegation.stake, + minimum_delegation + rent_exempt_reserve + ); + assert_eq!(stake.delegation.activation_epoch, 0); + assert_eq!(stake.delegation.deactivation_epoch, current_epoch); + } else { + panic!("Invalid output_accounts[0] data"); + } + assert_eq!( + output_accounts[1].lamports(), + minimum_delegation + rent_exempt_reserve + ); + if let StakeState::Stake(meta, stake) = + output_accounts[1].borrow().deserialize_data().unwrap() + { + assert_eq!(meta.rent_exempt_reserve, rent_exempt_reserve); + assert_eq!(stake.delegation.stake, minimum_delegation); + assert_eq!(stake.delegation.activation_epoch, current_epoch); + assert_eq!(stake.delegation.deactivation_epoch, u64::MAX); + } else { + panic!("Invalid output_accounts[1] data"); + } + + // + // Variations of rescinding the deactivation of `stake_account` + // + let deactivated_stake_accounts = [ + ( + // Failure: insufficient stake in `stake_account` to even delegate normally + { + let mut deactivated_stake_account = output_accounts[0].clone(); + deactivated_stake_account + .checked_add_lamports(minimum_delegation - 1) + .unwrap(); + deactivated_stake_account + }, + Err(StakeError::InsufficientDelegation.into()), + ), + ( + // Failure: `stake_account` holds the "virtual stake" that's cooling now, with the + // real stake now warming up in `uninitialized_stake_account` + { + let mut deactivated_stake_account = output_accounts[0].clone(); + deactivated_stake_account + .checked_add_lamports(minimum_delegation) + .unwrap(); + deactivated_stake_account + }, + Err(StakeError::TooSoonToRedelegate.into()), + ), + ( + // Success: `stake_account` has been replenished with additional lamports to + // fully realize its "virtual stake" + { + let mut deactivated_stake_account = output_accounts[0].clone(); + deactivated_stake_account + .checked_add_lamports(minimum_delegation + rent_exempt_reserve) + .unwrap(); + deactivated_stake_account + }, + Ok(()), + ), + ( + // Failure: `stake_account` has been replenished with 1 lamport less than what's + // necessary to fully realize its "virtual stake" + { + let mut deactivated_stake_account = output_accounts[0].clone(); + deactivated_stake_account + .checked_add_lamports(minimum_delegation + rent_exempt_reserve - 1) + .unwrap(); + deactivated_stake_account + }, + Err(StakeError::TooSoonToRedelegate.into()), + ), + ]; + for (deactivated_stake_account, expected_result) in deactivated_stake_accounts { + let _ = process_instruction_with_overrides( + &serialize(&StakeInstruction::DelegateStake).unwrap(), + vec![ + (stake_address, deactivated_stake_account), + (vote_address, new_vote_account.clone()), + ( + sysvar::clock::id(), + account::create_account_shared_data_for_test(&Clock::default()), + ), + ( + sysvar::stake_history::id(), + account::create_account_shared_data_for_test(&StakeHistory::default()), + ), + ( + stake_config::id(), + config::create_account(0, &stake_config::Config::default()), + ), + (authorized_staker, AccountSharedData::default()), + ], + vec![ + AccountMeta { + pubkey: stake_address, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: vote_address, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: sysvar::clock::id(), + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: sysvar::stake_history::id(), + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: stake_config::id(), + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: authorized_staker, + is_signer: true, + is_writable: false, + }, + ], + Some(&sysvar_cache_override), + Some(Arc::clone(&feature_set)), + expected_result, + ); + } + + // + // Success: `uninitialized_stake_account` starts with 42 extra lamports + // + let uninitialized_stake_account_with_extra_lamports = + AccountSharedData::new(42 /* lamports */, StakeState::size_of(), &id()); + let output_accounts = process_instruction_redelegate( + &stake_address, + &stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account_with_extra_lamports, + Ok(()), + ); + + assert_eq!(output_accounts[0].lamports(), rent_exempt_reserve); + assert_eq!( + output_accounts[1].lamports(), + minimum_delegation + rent_exempt_reserve + 42 + ); + if let StakeState::Stake(meta, stake) = + output_accounts[1].borrow().deserialize_data().unwrap() + { + assert_eq!(meta.rent_exempt_reserve, rent_exempt_reserve); + assert_eq!(stake.delegation.stake, minimum_delegation + 42); + assert_eq!(stake.delegation.activation_epoch, current_epoch); + assert_eq!(stake.delegation.deactivation_epoch, u64::MAX); + } else { + panic!("Invalid output_accounts[1] data"); + } + + // + // Success: `stake_account` is over-allocated and holds a greater than required `rent_exempt_reserve` + // + let mut stake_account_over_allocated = + prepare_stake_account(0 /*activation_epoch:*/, None); + if let StakeState::Stake(mut meta, stake) = stake_account_over_allocated + .borrow_mut() + .deserialize_data() + .unwrap() + { + meta.rent_exempt_reserve += 42; + stake_account_over_allocated + .set_state(&StakeState::Stake(meta, stake)) + .unwrap(); + } + stake_account_over_allocated + .checked_add_lamports(42) + .unwrap(); + assert_eq!( + stake_account_over_allocated.lamports(), + (minimum_delegation + rent_exempt_reserve) + (rent_exempt_reserve + 42), + ); + assert_eq!(uninitialized_stake_account.lamports(), 0); + let output_accounts = process_instruction_redelegate( + &stake_address, + &stake_account_over_allocated, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Ok(()), + ); + + assert_eq!(output_accounts[0].lamports(), rent_exempt_reserve + 42); + if let StakeState::Stake(meta, _stake) = + output_accounts[0].borrow().deserialize_data().unwrap() + { + assert_eq!(meta.rent_exempt_reserve, rent_exempt_reserve + 42); + } else { + panic!("Invalid output_accounts[0] data"); + } + assert_eq!( + output_accounts[1].lamports(), + minimum_delegation + rent_exempt_reserve, + ); + if let StakeState::Stake(meta, stake) = + output_accounts[1].borrow().deserialize_data().unwrap() + { + assert_eq!(meta.rent_exempt_reserve, rent_exempt_reserve); + assert_eq!(stake.delegation.stake, minimum_delegation); + } else { + panic!("Invalid output_accounts[1] data"); + } + + // + // Failure: `uninitialized_stake_account` with invalid program id + // + let _ = process_instruction_redelegate( + &stake_address, + &stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &AccountSharedData::new( + 0, /* lamports */ + StakeState::size_of(), + &Pubkey::new_unique(), // <-- Invalid program id + ), + Err(InstructionError::IncorrectProgramId), + ); + + // + // Failure: `uninitialized_stake_account` with size too small + // + let _ = process_instruction_redelegate( + &stake_address, + &stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &AccountSharedData::new(0 /* lamports */, StakeState::size_of() - 1, &id()), // <-- size too small + Err(InstructionError::InvalidAccountData), + ); + + // + // Failure: `uninitialized_stake_account` with size too large + // + let _ = process_instruction_redelegate( + &stake_address, + &stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &AccountSharedData::new(0 /* lamports */, StakeState::size_of() + 1, &id()), // <-- size too large + Err(InstructionError::InvalidAccountData), + ); + + // + // Failure: `uninitialized_stake_account` with initialized stake account + // + let _ = process_instruction_redelegate( + &stake_address, + &stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &stake_account.clone(), // <-- Initialized stake account + Err(InstructionError::AccountAlreadyInitialized), + ); + + // + // Failure: invalid `new_vote_account` + // + let _ = process_instruction_redelegate( + &stake_address, + &stake_account, + &authorized_staker, + &new_vote_address, + &uninitialized_stake_account.clone(), // <-- Invalid vote account + &uninitialized_stake_address, + &uninitialized_stake_account, + Err(InstructionError::IncorrectProgramId), + ); + + // + // Failure: invalid `stake_account` + // + let _ = process_instruction_redelegate( + &stake_address, + &uninitialized_stake_account.clone(), // <-- Uninitialized stake account + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Err(InstructionError::InvalidAccountData), + ); + + // + // Failure: stake is inactive, activating or deactivating + // + let inactive_stake_account = prepare_stake_account( + current_epoch + 1, /*activation_epoch*/ + Some(StakeActivationStatus { + effective: 0, + activating: 0, + deactivating: 0, + }), + ); + let _ = process_instruction_redelegate( + &stake_address, + &inactive_stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Err(StakeError::RedelegateTransientOrInactiveStake.into()), + ); + + let activating_stake_account = prepare_stake_account( + current_epoch, /*activation_epoch*/ + Some(StakeActivationStatus { + effective: 0, + activating: minimum_delegation + rent_exempt_reserve, + deactivating: 0, + }), + ); + let _ = process_instruction_redelegate( + &stake_address, + &activating_stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Err(StakeError::RedelegateTransientOrInactiveStake.into()), + ); + + let mut deactivating_stake_account = + prepare_stake_account(0 /*activation_epoch:*/, None); + if let StakeState::Stake(meta, mut stake) = deactivating_stake_account + .borrow_mut() + .deserialize_data() + .unwrap() + { + stake.deactivate(current_epoch).unwrap(); + assert_eq!( + StakeActivationStatus { + effective: minimum_delegation + rent_exempt_reserve, + activating: 0, + deactivating: minimum_delegation + rent_exempt_reserve, + }, + stake + .delegation + .stake_activating_and_deactivating(current_epoch, Some(&stake_history)) + ); + + deactivating_stake_account + .set_state(&StakeState::Stake(meta, stake)) + .unwrap(); + } + let _ = process_instruction_redelegate( + &stake_address, + &deactivating_stake_account, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Err(StakeError::RedelegateTransientOrInactiveStake.into()), + ); + + // + // Failure: `stake_account` has insufficient stake + // (less than `minimum_delegation + rent_exempt_reserve`) + // + let mut stake_account_too_few_lamports = stake_account.clone(); + if let StakeState::Stake(meta, mut stake) = stake_account_too_few_lamports + .borrow_mut() + .deserialize_data() + .unwrap() + { + stake.delegation.stake -= 1; + assert_eq!( + stake.delegation.stake, + minimum_delegation + rent_exempt_reserve - 1 + ); + stake_account_too_few_lamports + .set_state(&StakeState::Stake(meta, stake)) + .unwrap(); + } else { + panic!("Invalid stake_account"); + } + stake_account_too_few_lamports + .checked_sub_lamports(1) + .unwrap(); + assert_eq!( + stake_account_too_few_lamports.lamports(), + minimum_delegation + 2 * rent_exempt_reserve - 1 + ); + + let _ = process_instruction_redelegate( + &stake_address, + &stake_account_too_few_lamports, + &authorized_staker, + &new_vote_address, + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Err(StakeError::InsufficientDelegation.into()), + ); + + // + // Failure: redelegate to same vote addresss + // + let _ = process_instruction_redelegate( + &stake_address, + &stake_account, + &authorized_staker, + &vote_address, // <-- Same vote address + &new_vote_account, + &uninitialized_stake_address, + &uninitialized_stake_account, + Err(StakeError::RedelegateToSameVoteAccount.into()), + ); + } } diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index ddd87cd3cdaa7d..a3d55e3d10ca5a 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -94,7 +94,8 @@ pub fn meta_from(account: &AccountSharedData) -> Option { from(account).and_then(|state: StakeState| state.meta()) } -fn redelegate( +fn redelegate_stake( + invoke_context: &InvokeContext, stake: &mut Stake, stake_lamports: u64, voter_pubkey: &Pubkey, @@ -105,11 +106,25 @@ fn redelegate( ) -> Result<(), StakeError> { // If stake is currently active: if stake.stake(clock.epoch, Some(stake_history)) != 0 { + let stake_lamports_ok = if invoke_context + .feature_set + .is_active(&feature_set::stake_redelegate_instruction::id()) + { + // When a stake account is redelegated, the delegated lamports from the source stake + // account are transferred to a new stake account. Do not permit the deactivation of + // the source stake account to be rescinded, by more generally requiring the delegation + // be configured with the expected amount of stake lamports before rescinding. + stake_lamports >= stake.delegation.stake + } else { + true + }; + // If pubkey of new voter is the same as current, // and we are scheduled to start deactivating this epoch, // we rescind deactivation if stake.delegation.voter_pubkey == *voter_pubkey && clock.epoch == stake.delegation.deactivation_epoch + && stake_lamports_ok { stake.delegation.deactivation_epoch = std::u64::MAX; return Ok(()); @@ -556,7 +571,9 @@ pub fn authorize_with_seed( ) } +#[allow(clippy::too_many_arguments)] pub fn delegate( + invoke_context: &InvokeContext, transaction_context: &TransactionContext, instruction_context: &InstructionContext, stake_account_index: usize, @@ -596,7 +613,8 @@ pub fn delegate( meta.authorized.check(signers, StakeAuthorize::Staker)?; let ValidatedDelegatedInfo { stake_amount } = validate_delegated_amount(&stake_account, &meta, feature_set)?; - redelegate( + redelegate_stake( + invoke_context, &mut stake, stake_amount, &vote_pubkey, @@ -856,6 +874,127 @@ pub fn merge( Ok(()) } +pub fn redelegate( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + instruction_context: &InstructionContext, + stake_account: &mut BorrowedAccount, + uninitialized_stake_account_index: usize, + vote_account_index: usize, + config: &Config, + signers: &HashSet, +) -> Result<(), InstructionError> { + let clock = invoke_context.get_sysvar_cache().get_clock()?; + + // ensure `uninitialized_stake_account_index` is in the uninitialized state + let mut uninitialized_stake_account = instruction_context + .try_borrow_instruction_account(transaction_context, uninitialized_stake_account_index)?; + if *uninitialized_stake_account.get_owner() != id() { + ic_msg!( + invoke_context, + "expected uninitialized stake account owner to be {}, not {}", + id(), + *uninitialized_stake_account.get_owner() + ); + return Err(InstructionError::IncorrectProgramId); + } + if uninitialized_stake_account.get_data().len() != StakeState::size_of() { + ic_msg!( + invoke_context, + "expected uninitialized stake account data len to be {}, not {}", + StakeState::size_of(), + uninitialized_stake_account.get_data().len() + ); + return Err(InstructionError::InvalidAccountData); + } + if !matches!( + uninitialized_stake_account.get_state()?, + StakeState::Uninitialized + ) { + ic_msg!( + invoke_context, + "expected uninitialized stake account to be uninitialized", + ); + return Err(InstructionError::AccountAlreadyInitialized); + } + + // validate the provided vote account + let vote_account = instruction_context + .try_borrow_instruction_account(transaction_context, vote_account_index)?; + if *vote_account.get_owner() != solana_vote_program::id() { + ic_msg!( + invoke_context, + "expected vote account owner to be {}, not {}", + solana_vote_program::id(), + *vote_account.get_owner() + ); + return Err(InstructionError::IncorrectProgramId); + } + let vote_pubkey = *vote_account.get_key(); + let vote_state = vote_account.get_state::()?; + + 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)); + if status.effective == 0 || status.activating != 0 || status.deactivating != 0 { + ic_msg!(invoke_context, "stake is not active"); + return Err(StakeError::RedelegateTransientOrInactiveStake.into()); + } + + // Deny redelegating to the same vote account. This is nonsensical and could be used to + // grief the global stake warm-up/cool-down rate + if stake.delegation.voter_pubkey == vote_pubkey { + ic_msg!( + invoke_context, + "redelegating to the same vote account not permitted" + ); + return Err(StakeError::RedelegateToSameVoteAccount.into()); + } + + (meta, status.effective) + } else { + ic_msg!(invoke_context, "invalid stake account data",); + return Err(InstructionError::InvalidAccountData); + }; + + // deactivate `stake_account` + // + // Note: This function also ensures `signers` contains the `StakeAuthorize::Staker` + deactivate(stake_account, &clock, signers)?; + + // transfer the effective stake to the uninitialized stake account + stake_account.checked_sub_lamports(effective_stake)?; + uninitialized_stake_account.checked_add_lamports(effective_stake)?; + + // initialize and schedule `uninitialized_stake_account` for activation + let sysvar_cache = invoke_context.get_sysvar_cache(); + let rent = sysvar_cache.get_rent()?; + let mut uninitialized_stake_meta = stake_meta; + uninitialized_stake_meta.rent_exempt_reserve = + rent.minimum_balance(uninitialized_stake_account.get_data().len()); + + let ValidatedDelegatedInfo { stake_amount } = validate_delegated_amount( + &uninitialized_stake_account, + &uninitialized_stake_meta, + &invoke_context.feature_set, + )?; + uninitialized_stake_account.set_state(&StakeState::Stake( + uninitialized_stake_meta, + new_stake( + stake_amount, + &vote_pubkey, + &vote_state.convert_to_current(), + clock.epoch, + config, + ), + ))?; + + Ok(()) +} + #[allow(clippy::too_many_arguments)] pub fn withdraw( transaction_context: &TransactionContext, diff --git a/sdk/program/src/stake/instruction.rs b/sdk/program/src/stake/instruction.rs index 9a7601a640807f..67d784c38d4a43 100644 --- a/sdk/program/src/stake/instruction.rs +++ b/sdk/program/src/stake/instruction.rs @@ -60,6 +60,12 @@ pub enum StakeError { #[error("delegation amount is less than the minimum")] InsufficientDelegation, + + #[error("stake account with transient or inactive stake cannot be redelegated")] + RedelegateTransientOrInactiveStake, + + #[error("stake redelegation to the same vote account is not permitted")] + RedelegateToSameVoteAccount, } impl DecodeError for StakeError { @@ -261,6 +267,28 @@ pub enum StakeInstruction { /// 2. `[]` Reference vote account that has voted at least once in the last /// `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION` epochs DeactivateDelinquent, + + /// Redelegate activated stake to another vote account. + /// + /// Upon success: + /// * the balance of the delegated stake account will be reduced to the undelegated amount in + /// the account (rent exempt minimum and any additional lamports not part of the delegation), + /// and scheduled for deactivation. + /// * the provided uninitialized stake account will receive the original balance of the + /// delegated stake account, minus the rent exempt minimum, and scheduled for activation to + /// the provided vote account. Any existing lamports in the uninitialized stake account + /// will also be included in the re-delegation. + /// + /// # Account references + /// 0. `[WRITE]` Delegated stake account to be redelegated. The account must be fully + /// activated and carry a balance greater than or equal to the minimum delegation amount + /// plus rent exempt minimum + /// 1. `[WRITE]` Uninitialized stake account that will hold the redelegated stake + /// 2. `[]` Vote account to which this stake will be re-delegated + /// 3. `[]` Address of config account that carries stake config + /// 4. `[SIGNER]` Stake authority + /// + Redelegate, } #[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)] @@ -738,6 +766,65 @@ pub fn deactivate_delinquent_stake( Instruction::new_with_bincode(id(), &StakeInstruction::DeactivateDelinquent, account_metas) } +fn _redelegate( + stake_pubkey: &Pubkey, + authorized_pubkey: &Pubkey, + vote_pubkey: &Pubkey, + uninitialized_stake_pubkey: &Pubkey, +) -> Instruction { + let account_metas = vec![ + AccountMeta::new(*stake_pubkey, false), + AccountMeta::new(*uninitialized_stake_pubkey, false), + AccountMeta::new_readonly(*vote_pubkey, false), + AccountMeta::new_readonly(config::id(), false), + AccountMeta::new_readonly(*authorized_pubkey, true), + ]; + Instruction::new_with_bincode(id(), &StakeInstruction::Redelegate, account_metas) +} + +pub fn redelegate( + stake_pubkey: &Pubkey, + authorized_pubkey: &Pubkey, + vote_pubkey: &Pubkey, + uninitialized_stake_pubkey: &Pubkey, +) -> Vec { + vec![ + system_instruction::allocate(uninitialized_stake_pubkey, StakeState::size_of() as u64), + system_instruction::assign(uninitialized_stake_pubkey, &id()), + _redelegate( + stake_pubkey, + authorized_pubkey, + vote_pubkey, + uninitialized_stake_pubkey, + ), + ] +} + +pub fn redelegate_with_seed( + stake_pubkey: &Pubkey, + authorized_pubkey: &Pubkey, + vote_pubkey: &Pubkey, + uninitialized_stake_pubkey: &Pubkey, // derived using create_with_seed() + base: &Pubkey, // base + seed: &str, // seed +) -> Vec { + vec![ + system_instruction::allocate_with_seed( + uninitialized_stake_pubkey, + base, + seed, + StakeState::size_of() as u64, + &id(), + ), + _redelegate( + stake_pubkey, + authorized_pubkey, + vote_pubkey, + uninitialized_stake_pubkey, + ), + ] +} + #[cfg(test)] mod tests { use {super::*, crate::instruction::InstructionError}; diff --git a/sdk/src/feature_set.rs b/sdk/src/feature_set.rs index 6708fcf417d5f3..899120dada206e 100644 --- a/sdk/src/feature_set.rs +++ b/sdk/src/feature_set.rs @@ -287,6 +287,10 @@ pub mod stake_deactivate_delinquent_instruction { solana_sdk::declare_id!("437r62HoAdUb63amq3D7ENnBLDhHT2xY8eFkLJYVKK4x"); } +pub mod stake_redelegate_instruction { + solana_sdk::declare_id!("3EPmAX94PvVJCjMeFfRFvj4avqCPL8vv3TGsZQg7ydMx"); +} + pub mod vote_withdraw_authority_may_change_authorized_voter { solana_sdk::declare_id!("AVZS3ZsN4gi6Rkx2QUibYuSJG3S6QHib7xCYhG6vGJxU"); } @@ -586,6 +590,7 @@ lazy_static! { (nonce_must_be_advanceable::id(), "durable nonces must be advanceable"), (vote_authorize_with_seed::id(), "An instruction you can use to change a vote accounts authority when the current authority is a derived key #25860"), (cap_accounts_data_size_per_block::id(), "cap the accounts data size per block #25517"), + (stake_redelegate_instruction::id(), "enable the redelegate stake instruction #26294"), (preserve_rent_epoch_for_rent_exempt_accounts::id(), "preserve rent epoch for rent exempt accounts #26479"), (enable_bpf_loader_extend_program_data_ix::id(), "enable bpf upgradeable loader ExtendProgramData instruction #25234"), (enable_early_verification_of_account_modifications::id(), "enable early verification of account modifications #25899"), diff --git a/transaction-status/src/parse_stake.rs b/transaction-status/src/parse_stake.rs index f1f9ff86fa8438..a973643ed26181 100644 --- a/transaction-status/src/parse_stake.rs +++ b/transaction-status/src/parse_stake.rs @@ -284,6 +284,19 @@ pub fn parse_stake( }), }) } + StakeInstruction::Redelegate => { + check_num_stake_accounts(&instruction.accounts, 4)?; + Ok(ParsedInstructionEnum { + instruction_type: "redelegate".to_string(), + info: json!({ + "stakeAccount": account_keys[instruction.accounts[0] as usize].to_string(), + "newStakeAccount": account_keys[instruction.accounts[1] as usize].to_string(), + "voteAccount": account_keys[instruction.accounts[2] as usize].to_string(), + "stakeConfigAccount": account_keys[instruction.accounts[3] as usize].to_string(), + "stakeAuthority": account_keys[instruction.accounts[4] as usize].to_string(), + }), + }) + } } }