diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 2d8807fca909dd..0de948de1551a8 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, + redelegate_stake_account_pubkey: Option, }, SplitStake { stake_account_pubkey: Pubkey, @@ -680,6 +681,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) } @@ -1132,6 +1136,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { nonce_authority, memo, fee_payer, + redelegate_stake_account_pubkey, } => process_delegate_stake( &rpc_client, config, @@ -1146,6 +1151,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { *nonce_authority, memo.as_ref(), *fee_payer, + redelegate_stake_account_pubkey.as_ref(), ), CliCommand::SplitStake { stake_account_pubkey, diff --git a/cli/src/stake.rs b/cli/src/stake.rs index f6a76f7e95aae3..4314a04798858a 100644 --- a/cli/src/stake.rs +++ b/cli/src/stake.rs @@ -284,6 +284,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("redelegate_stake_account") + .index(3) + .value_name("REDELEGATED_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") @@ -742,6 +787,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 (redelegate_stake_account, redelegate_stake_account_pubkey) = + signer_of(matches, "redelegate_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); @@ -754,7 +801,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, redelegate_stake_account]; if nonce_account.is_some() { bulk_signers.push(nonce_authority); } @@ -774,6 +821,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(), + redelegate_stake_account_pubkey, }, signers: signer_info.signers, }) @@ -2393,11 +2441,21 @@ pub fn process_delegate_stake( nonce_authority: SignerIndex, memo: Option<&String>, fee_payer: SignerIndex, + redelegate_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(redelegate_stake_account_pubkey) = &redelegate_stake_account_pubkey { + check_unique_pubkeys( + (stake_account_pubkey, "stake_account_pubkey".to_string()), + ( + redelegate_stake_account_pubkey, + "redelegate_stake_account".to_string(), + ), + )?; + } let stake_authority = config.signers[stake_authority]; if !sign_only { @@ -2450,12 +2508,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(redelegate_stake_account_pubkey) = &redelegate_stake_account_pubkey { + stake_instruction::redelegate( + stake_account_pubkey, + &stake_authority.pubkey(), + vote_account_pubkey, + redelegate_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]; @@ -3824,6 +3892,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }, signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], } @@ -3855,6 +3924,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -3888,6 +3958,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }, signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], } @@ -3922,6 +3993,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }, signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], } @@ -3951,6 +4023,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }, signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], } @@ -3990,6 +4063,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 1, + redelegate_stake_account_pubkey: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -4038,6 +4112,7 @@ mod tests { nonce_authority: 2, memo: None, fee_payer: 1, + redelegate_stake_account_pubkey: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -4074,6 +4149,7 @@ mod tests { nonce_authority: 0, memo: None, fee_payer: 1, + redelegate_stake_account_pubkey: None, }, signers: vec![ read_keypair_file(&default_keypair_file).unwrap().into(), @@ -4082,6 +4158,51 @@ mod tests { } ); + // Test RedelegateStake Subcommand (minimal test due to the significant implementation + // overlap with DelegateStake) + let (redelegate_stake_account_keypair_file, mut redelegate_stake_account_tmp_file) = + make_tmp_file(); + let redelegate_stake_account_keypair = Keypair::new(); + write_keypair( + &redelegate_stake_account_keypair, + redelegate_stake_account_tmp_file.as_file_mut(), + ) + .unwrap(); + let redelegate_stake_account_pubkey = redelegate_stake_account_keypair.pubkey(); + + let test_redelegate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "redelegate-stake", + &stake_account_string, + &vote_account_string, + &redelegate_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, + redelegate_stake_account_pubkey: Some(redelegate_stake_account_pubkey), + }, + signers: vec![ + read_keypair_file(&default_keypair_file).unwrap().into(), + read_keypair_file(&redelegate_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 edcebd31742a47..ef064fb386ab84 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,222 @@ 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, + redelegate_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()); + + // Redelegate to `vote2_keypair` + let stake2_keypair = Keypair::new(); + 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, + redelegate_stake_account_pubkey: Some(stake2_keypair.pubkey()), + }; + process_command(&config).unwrap(); + + // check that all the stake, save `rent_exempt_reserve`, have been moved from `stake_keypair` + // to `stake2_keypair` + check_balance!( + 50_000_000_000 - rent_exempt_reserve, + &rpc_client, + &stake2_keypair.pubkey(), + ); + check_balance!(rent_exempt_reserve, &rpc_client, &stake_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: 50_000_000_000 - rent_exempt_reserve, + } + ); + + // `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 * 2, + inactive: 0 + } + ); +} + #[test] fn test_stake_delegation_force() { let mint_keypair = Keypair::new(); @@ -113,6 +330,7 @@ fn test_stake_delegation_force() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }; process_command(&config).unwrap_err(); @@ -129,6 +347,7 @@ fn test_stake_delegation_force() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }; process_command(&config).unwrap(); } @@ -205,6 +424,7 @@ fn test_seed_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }; process_command(&config_validator).unwrap(); @@ -293,6 +513,7 @@ fn test_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }; process_command(&config_validator).unwrap(); @@ -405,6 +626,7 @@ fn test_offline_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }; config_offline.output_format = OutputFormat::JsonCompact; let sig_response = process_command(&config_offline).unwrap(); @@ -426,6 +648,7 @@ fn test_offline_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }; process_command(&config_payer).unwrap(); @@ -558,6 +781,7 @@ fn test_nonced_stake_delegation_and_deactivation() { nonce_authority: 0, memo: None, fee_payer: 0, + redelegate_stake_account_pubkey: None, }; process_command(&config).unwrap();