From 57ff7371b402d52d59dbd3555a181c415e8ed30c Mon Sep 17 00:00:00 2001 From: Michael Vines Date: Fri, 25 Mar 2022 09:11:51 -0700 Subject: [PATCH] Add StakeInstruction::DeactivateDelinquent --- cli/src/cli.rs | 4 + cli/src/stake.rs | 121 +++++++- cli/src/vote.rs | 2 +- cli/tests/stake.rs | 5 + programs/stake/src/stake_instruction.rs | 367 +++++++++++++++++++++++- programs/stake/src/stake_state.rs | 54 +++- programs/vote/src/vote_state/mod.rs | 4 +- sdk/program/src/stake/instruction.rs | 37 +++ sdk/program/src/stake/mod.rs | 4 + sdk/program/src/stake/tools.rs | 118 +++++++- sdk/src/feature_set.rs | 5 + transaction-status/src/parse_stake.rs | 11 + 12 files changed, 715 insertions(+), 17 deletions(-) diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 697535826c..d0ffad4cbc 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -188,6 +188,7 @@ pub enum CliCommand { stake_account_pubkey: Pubkey, stake_authority: SignerIndex, sign_only: bool, + deactivate_delinquent: bool, dump_transaction_message: bool, blockhash_query: BlockhashQuery, nonce_account: Option, @@ -1083,6 +1084,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { stake_account_pubkey, stake_authority, sign_only, + deactivate_delinquent, dump_transaction_message, blockhash_query, nonce_account, @@ -1096,6 +1098,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { stake_account_pubkey, *stake_authority, *sign_only, + *deactivate_delinquent, *dump_transaction_message, blockhash_query, *nonce_account, @@ -2092,6 +2095,7 @@ mod tests { stake_authority: 0, sign_only: false, dump_transaction_message: false, + deactivate_delinquent: false, blockhash_query: BlockhashQuery::default(), nonce_account: None, nonce_authority: 0, diff --git a/cli/src/stake.rs b/cli/src/stake.rs index daeddfb61e..b6b8ecdaa4 100644 --- a/cli/src/stake.rs +++ b/cli/src/stake.rs @@ -41,6 +41,7 @@ use { self, instruction::{self as stake_instruction, LockupArgs, StakeError}, state::{Authorized, Lockup, Meta, StakeActivationStatus, StakeAuthorize, StakeState}, + tools::{acceptable_reference_epoch_credits, eligible_for_deactivate_delinquent}, }, stake_history::StakeHistory, system_instruction::SystemError, @@ -379,6 +380,13 @@ impl StakeSubCommands for App<'_, '_> { .help("Seed for address generation; if specified, the resulting account \ will be at a derived address of STAKE_ACCOUNT_ADDRESS") ) + .arg( + Arg::with_name("delinquent") + .long("delinquent") + .takes_value(false) + .conflicts_with(SIGN_ONLY_ARG.name) + .help("Deactivate abandoned stake that is currently delegated to a delinquent vote account") + ) .arg(stake_authority_arg()) .offline_args() .nonce_args(false) @@ -995,11 +1003,13 @@ pub fn parse_stake_deactivate_stake( let stake_account_pubkey = pubkey_of_signer(matches, "stake_account_pubkey", wallet_manager)?.unwrap(); let sign_only = matches.is_present(SIGN_ONLY_ARG.name); + let deactivate_delinquent = matches.is_present("delinquent"); let dump_transaction_message = matches.is_present(DUMP_TRANSACTION_MESSAGE.name); let blockhash_query = BlockhashQuery::new_from_matches(matches); let nonce_account = pubkey_of(matches, NONCE_ARG.name); let memo = matches.value_of(MEMO_ARG.name).map(String::from); let seed = value_t!(matches, "seed", String).ok(); + let (stake_authority, stake_authority_pubkey) = signer_of(matches, STAKE_AUTHORITY_ARG.name, wallet_manager)?; let (nonce_authority, nonce_authority_pubkey) = @@ -1018,6 +1028,7 @@ pub fn parse_stake_deactivate_stake( stake_account_pubkey, stake_authority: signer_info.index_of(stake_authority_pubkey).unwrap(), sign_only, + deactivate_delinquent, dump_transaction_message, blockhash_query, nonce_account, @@ -1477,6 +1488,7 @@ pub fn process_deactivate_stake_account( stake_account_pubkey: &Pubkey, stake_authority: SignerIndex, sign_only: bool, + deactivate_delinquent: bool, dump_transaction_message: bool, blockhash_query: &BlockhashQuery, nonce_account: Option, @@ -1486,7 +1498,6 @@ pub fn process_deactivate_stake_account( fee_payer: SignerIndex, ) -> ProcessResult { let recent_blockhash = blockhash_query.get_blockhash(rpc_client, config.commitment)?; - let stake_authority = config.signers[stake_authority]; let stake_account_address = if let Some(seed) = seed { Pubkey::create_with_seed(stake_account_pubkey, seed, &stake::program::id())? @@ -1494,11 +1505,77 @@ pub fn process_deactivate_stake_account( *stake_account_pubkey }; - let ixs = vec![stake_instruction::deactivate_stake( - &stake_account_address, - &stake_authority.pubkey(), - )] + let ixs = vec![if deactivate_delinquent { + let stake_account = rpc_client.get_account(&stake_account_address)?; + if stake_account.owner != stake::program::id() { + return Err(CliError::BadParameter(format!( + "{} is not a stake account", + stake_account_address, + )) + .into()); + } + + let vote_account_address = match stake_account.state() { + Ok(stake_state) => match stake_state { + StakeState::Stake(_, stake) => stake.delegation.voter_pubkey, + _ => { + return Err(CliError::BadParameter(format!( + "{} is not a delegated stake account", + stake_account_address, + )) + .into()) + } + }, + Err(err) => { + return Err(CliError::RpcRequestError(format!( + "Account data could not be deserialized to stake state: {}", + err + )) + .into()) + } + }; + + let current_epoch = rpc_client.get_epoch_info()?.epoch; + + let (_, vote_state) = crate::vote::get_vote_account( + rpc_client, + &vote_account_address, + rpc_client.commitment(), + )?; + if !eligible_for_deactivate_delinquent(&vote_state.epoch_credits, current_epoch) { + return Err(CliError::BadParameter(format!( + "Stake has not been delinquent for {} epochs", + stake::MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION, + )) + .into()); + } + + // Search for a reference vote account + let reference_vote_account_address = rpc_client + .get_vote_accounts()? + .current + .into_iter() + .find(|vote_account_info| { + acceptable_reference_epoch_credits(&vote_account_info.epoch_credits, current_epoch) + }); + let reference_vote_account_address = reference_vote_account_address + .ok_or_else(|| { + CliError::RpcRequestError("Unable to find a reference vote account".into()) + })? + .vote_pubkey + .parse()?; + + stake_instruction::deactivate_delinquent_stake( + &stake_account_address, + &vote_account_address, + &reference_vote_account_address, + ) + } else { + let stake_authority = config.signers[stake_authority]; + stake_instruction::deactivate_stake(&stake_account_address, &stake_authority.pubkey()) + }] .with_memo(memo); + let nonce_authority = config.signers[nonce_authority]; let fee_payer = config.signers[fee_payer]; @@ -4174,6 +4251,34 @@ mod tests { stake_account_pubkey, stake_authority: 0, sign_only: false, + deactivate_delinquent: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::default(), + nonce_account: None, + nonce_authority: 0, + memo: None, + seed: None, + fee_payer: 0, + }, + signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], + } + ); + + // Test DeactivateStake Subcommand with delinquent flag + let test_deactivate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "deactivate-stake", + &stake_account_string, + "--delinquent", + ]); + assert_eq!( + parse_command(&test_deactivate_stake, &default_signer, &mut None).unwrap(), + CliCommandInfo { + command: CliCommand::DeactivateStake { + stake_account_pubkey, + stake_authority: 0, + sign_only: false, + deactivate_delinquent: true, dump_transaction_message: false, blockhash_query: BlockhashQuery::default(), nonce_account: None, @@ -4201,6 +4306,7 @@ mod tests { stake_account_pubkey, stake_authority: 1, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::default(), nonce_account: None, @@ -4235,6 +4341,7 @@ mod tests { stake_account_pubkey, stake_authority: 0, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::FeeCalculator( blockhash_query::Source::Cluster, @@ -4265,6 +4372,7 @@ mod tests { stake_account_pubkey, stake_authority: 0, sign_only: true, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::None(blockhash), nonce_account: None, @@ -4299,6 +4407,7 @@ mod tests { stake_account_pubkey, stake_authority: 0, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::FeeCalculator( blockhash_query::Source::Cluster, @@ -4345,6 +4454,7 @@ mod tests { stake_account_pubkey, stake_authority: 0, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::FeeCalculator( blockhash_query::Source::NonceAccount(nonce_account), @@ -4379,6 +4489,7 @@ mod tests { stake_account_pubkey, stake_authority: 0, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::All(blockhash_query::Source::Cluster), nonce_account: None, diff --git a/cli/src/vote.rs b/cli/src/vote.rs index b7f200ff8f..d5629fe686 100644 --- a/cli/src/vote.rs +++ b/cli/src/vote.rs @@ -1140,7 +1140,7 @@ pub fn process_vote_update_commission( } } -fn get_vote_account( +pub(crate) fn get_vote_account( rpc_client: &RpcClient, vote_account_pubkey: &Pubkey, commitment_config: CommitmentConfig, diff --git a/cli/tests/stake.rs b/cli/tests/stake.rs index 7161ee8452..fb5514caaa 100644 --- a/cli/tests/stake.rs +++ b/cli/tests/stake.rs @@ -204,6 +204,7 @@ fn test_seed_stake_delegation_and_deactivation() { stake_account_pubkey: stake_address, stake_authority: 0, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::default(), nonce_account: None, @@ -287,6 +288,7 @@ fn test_stake_delegation_and_deactivation() { stake_account_pubkey: stake_keypair.pubkey(), stake_authority: 0, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::default(), nonce_account: None, @@ -412,6 +414,7 @@ fn test_offline_stake_delegation_and_deactivation() { stake_account_pubkey: stake_keypair.pubkey(), stake_authority: 0, sign_only: true, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::None(blockhash), nonce_account: None, @@ -431,6 +434,7 @@ fn test_offline_stake_delegation_and_deactivation() { stake_account_pubkey: stake_keypair.pubkey(), stake_authority: 0, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::FeeCalculator(blockhash_query::Source::Cluster, blockhash), nonce_account: None, @@ -546,6 +550,7 @@ fn test_nonced_stake_delegation_and_deactivation() { stake_account_pubkey: stake_keypair.pubkey(), stake_authority: 0, sign_only: false, + deactivate_delinquent: false, dump_transaction_message: false, blockhash_query: BlockhashQuery::FeeCalculator( blockhash_query::Source::NonceAccount(nonce_account.pubkey()), diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index 1eb822b5c4..339117539d 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -2,8 +2,8 @@ use { crate::{ config, stake_state::{ - authorize, authorize_with_seed, deactivate, delegate, initialize, merge, set_lockup, - split, withdraw, + authorize, authorize_with_seed, deactivate, deactivate_delinquent, delegate, + initialize, merge, set_lockup, split, withdraw, }, }, log::*, @@ -416,6 +416,27 @@ pub fn process_instruction( .transaction_context .set_return_data(id(), minimum_delegation) } + Ok(StakeInstruction::DeactivateDelinquent) => { + let mut me = get_stake_account()?; + if invoke_context + .feature_set + .is_active(&feature_set::stake_deactivate_delinquent_instruction::id()) + { + instruction_context.check_number_of_instruction_accounts(3)?; + + let clock = invoke_context.get_sysvar_cache().get_clock()?; + deactivate_delinquent( + transaction_context, + instruction_context, + &mut me, + first_instruction_account + 1, + first_instruction_account + 2, + clock.epoch, + ) + } else { + Err(InstructionError::InvalidInstructionData) + } + } Err(err) => { if !invoke_context.feature_set.is_active( &feature_set::add_get_minimum_delegation_instruction_to_stake_program::id(), @@ -432,8 +453,8 @@ mod tests { use { super::*, crate::stake_state::{ - authorized_from, create_stake_history_from_delegations, from, stake_from, Delegation, - Meta, Stake, StakeState, + authorized_from, create_stake_history_from_delegations, from, new_stake, stake_from, + Delegation, Meta, Stake, StakeState, }, bincode::serialize, solana_program_runtime::{ @@ -455,12 +476,13 @@ mod tests { LockupArgs, StakeError, }, state::{Authorized, Lockup, StakeAuthorize}, + MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION, }, stake_history::{StakeHistory, StakeHistoryEntry}, system_program, sysvar, }, solana_vote_program::vote_state::{self, VoteState, VoteStateVersions}, - std::{collections::HashSet, str::FromStr, sync::Arc}, + std::{borrow::BorrowMut, collections::HashSet, str::FromStr, sync::Arc}, }; fn create_default_account() -> AccountSharedData { @@ -661,6 +683,30 @@ mod tests { ), Err(InstructionError::InvalidAccountData), ); + process_instruction_as_one_arg( + &instruction::deactivate_delinquent_stake( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &invalid_vote_state_pubkey(), + ), + Err(InstructionError::IncorrectProgramId), + ); + process_instruction_as_one_arg( + &instruction::deactivate_delinquent_stake( + &Pubkey::new_unique(), + &invalid_vote_state_pubkey(), + &Pubkey::new_unique(), + ), + Err(InstructionError::InvalidAccountData), + ); + process_instruction_as_one_arg( + &instruction::deactivate_delinquent_stake( + &Pubkey::new_unique(), + &invalid_vote_state_pubkey(), + &invalid_vote_state_pubkey(), + ), + Err(InstructionError::InvalidAccountData), + ); } #[test] @@ -758,6 +804,14 @@ mod tests { ), Err(InstructionError::InvalidAccountOwner), ); + process_instruction_as_one_arg( + &instruction::deactivate_delinquent_stake( + &spoofed_stake_state_pubkey(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + ), + Err(InstructionError::InvalidAccountOwner), + ); } #[test] @@ -908,7 +962,7 @@ mod tests { &serialize(&StakeInstruction::Withdraw(withdrawal_amount)).unwrap(), vec![ (stake_address, stake_account.clone()), - (vote_address, vote_account), + (vote_address, vote_account.clone()), (rewards_address, rewards_account.clone()), (stake_history_address, stake_history_account), ], @@ -953,7 +1007,7 @@ mod tests { process_instruction( &serialize(&StakeInstruction::Deactivate).unwrap(), vec![ - (stake_address, stake_account), + (stake_address, stake_account.clone()), (rewards_address, rewards_account), ], vec![ @@ -978,6 +1032,41 @@ mod tests { Vec::new(), Err(InstructionError::NotEnoughAccountKeys), ); + + // Tests correct number of accounts are provided in deactivate_delinquent + process_instruction( + &serialize(&StakeInstruction::DeactivateDelinquent).unwrap(), + Vec::new(), + Vec::new(), + Err(InstructionError::NotEnoughAccountKeys), + ); + process_instruction( + &serialize(&StakeInstruction::DeactivateDelinquent).unwrap(), + vec![(stake_address, stake_account.clone())], + vec![AccountMeta { + pubkey: stake_address, + is_signer: false, + is_writable: false, + }], + Err(InstructionError::NotEnoughAccountKeys), + ); + process_instruction( + &serialize(&StakeInstruction::DeactivateDelinquent).unwrap(), + vec![(stake_address, stake_account), (vote_address, vote_account)], + vec![ + AccountMeta { + pubkey: stake_address, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: vote_address, + is_signer: false, + is_writable: false, + }, + ], + Err(InstructionError::NotEnoughAccountKeys), + ); } #[test] @@ -6128,4 +6217,268 @@ mod tests { ); } } + + #[test] + fn test_deactivate_delinquent() { + let mut sysvar_cache_override = SysvarCache::default(); + + let reference_vote_address = Pubkey::new_unique(); + let vote_address = Pubkey::new_unique(); + let stake_address = Pubkey::new_unique(); + + let initial_stake_state = StakeState::Stake( + Meta::default(), + new_stake( + 1, /* stake */ + &vote_address, + &VoteState::default(), + 1, /* activation_epoch */ + &stake_config::Config::default(), + ), + ); + + let stake_account = AccountSharedData::new_data_with_space( + 1, /* lamports */ + &initial_stake_state, + std::mem::size_of::(), + &id(), + ) + .unwrap(); + + let mut vote_account = AccountSharedData::new_data_with_space( + 1, /* lamports */ + &VoteStateVersions::new_current(VoteState::default()), + VoteState::size_of(), + &solana_vote_program::id(), + ) + .unwrap(); + + let mut reference_vote_account = AccountSharedData::new_data_with_space( + 1, /* lamports */ + &VoteStateVersions::new_current(VoteState::default()), + VoteState::size_of(), + &solana_vote_program::id(), + ) + .unwrap(); + + let current_epoch = 20; + + sysvar_cache_override.set_clock(Clock { + epoch: current_epoch, + ..Clock::default() + }); + + let process_instruction_deactivate_delinquent = + |stake_address: &Pubkey, + stake_account: &AccountSharedData, + vote_account: &AccountSharedData, + reference_vote_account: &AccountSharedData, + expected_result| { + process_instruction_with_sysvar_cache( + &serialize(&StakeInstruction::DeactivateDelinquent).unwrap(), + vec![ + (*stake_address, stake_account.clone()), + (vote_address, vote_account.clone()), + (reference_vote_address, reference_vote_account.clone()), + ], + vec![ + AccountMeta { + pubkey: *stake_address, + is_signer: false, + is_writable: true, + }, + AccountMeta { + pubkey: vote_address, + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: reference_vote_address, + is_signer: false, + is_writable: false, + }, + ], + Some(&sysvar_cache_override), + expected_result, + ) + }; + + // `reference_vote_account` has not voted. Instruction will fail + process_instruction_deactivate_delinquent( + &stake_address, + &stake_account, + &vote_account, + &reference_vote_account, + Err(StakeError::InsufficientReferenceVotes.into()), + ); + + // `reference_vote_account` has not consistently voted for at least + // `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION`. + // Instruction will fail + let mut reference_vote_state = VoteState::default(); + for epoch in 0..MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION / 2 { + reference_vote_state.increment_credits(epoch as Epoch); + } + reference_vote_account + .borrow_mut() + .serialize_data(&VoteStateVersions::new_current(reference_vote_state)) + .unwrap(); + + process_instruction_deactivate_delinquent( + &stake_address, + &stake_account, + &vote_account, + &reference_vote_account, + Err(StakeError::InsufficientReferenceVotes.into()), + ); + + // `reference_vote_account` has not consistently voted for the last + // `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION`. + // Instruction will fail + let mut reference_vote_state = VoteState::default(); + for epoch in 0..=current_epoch { + reference_vote_state.increment_credits(epoch); + } + assert_eq!( + reference_vote_state.epoch_credits[current_epoch as usize - 2].0, + current_epoch - 2 + ); + reference_vote_state + .epoch_credits + .remove(current_epoch as usize - 2); + assert_eq!( + reference_vote_state.epoch_credits[current_epoch as usize - 2].0, + current_epoch - 1 + ); + reference_vote_account + .borrow_mut() + .serialize_data(&VoteStateVersions::new_current(reference_vote_state)) + .unwrap(); + + process_instruction_deactivate_delinquent( + &stake_address, + &stake_account, + &vote_account, + &reference_vote_account, + Err(StakeError::InsufficientReferenceVotes.into()), + ); + + // `reference_vote_account` has consistently voted and `vote_account` has never voted. + // Instruction will succeed + let mut reference_vote_state = VoteState::default(); + for epoch in 0..=current_epoch { + reference_vote_state.increment_credits(epoch); + } + reference_vote_account + .borrow_mut() + .serialize_data(&VoteStateVersions::new_current(reference_vote_state)) + .unwrap(); + + let post_stake_account = &process_instruction_deactivate_delinquent( + &stake_address, + &stake_account, + &vote_account, + &reference_vote_account, + Ok(()), + )[0]; + + assert_eq!( + stake_from(post_stake_account) + .unwrap() + .delegation + .deactivation_epoch, + current_epoch + ); + + // `reference_vote_account` has consistently voted and `vote_account` has not voted for the + // last `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION`. + // Instruction will succeed + + let mut vote_state = VoteState::default(); + for epoch in 0..MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION / 2 { + vote_state.increment_credits(epoch as Epoch); + } + vote_account + .serialize_data(&VoteStateVersions::new_current(vote_state)) + .unwrap(); + + let post_stake_account = &process_instruction_deactivate_delinquent( + &stake_address, + &stake_account, + &vote_account, + &reference_vote_account, + Ok(()), + )[0]; + + assert_eq!( + stake_from(post_stake_account) + .unwrap() + .delegation + .deactivation_epoch, + current_epoch + ); + + // `reference_vote_account` has consistently voted and `vote_account` has not voted for the + // last `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION`. Try to deactivate an unrelated stake + // account. Instruction will fail + let unrelated_vote_address = Pubkey::new_unique(); + let unrelated_stake_address = Pubkey::new_unique(); + let mut unrelated_stake_account = stake_account.clone(); + assert_ne!(unrelated_vote_address, vote_address); + unrelated_stake_account + .serialize_data(&StakeState::Stake( + Meta::default(), + new_stake( + 1, /* stake */ + &unrelated_vote_address, + &VoteState::default(), + 1, /* activation_epoch */ + &stake_config::Config::default(), + ), + )) + .unwrap(); + + process_instruction_deactivate_delinquent( + &unrelated_stake_address, + &unrelated_stake_account, + &vote_account, + &reference_vote_account, + Err(StakeError::VoteAddressMismatch.into()), + ); + + // `reference_vote_account` has consistently voted and `vote_account` voted once + // `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION` ago. + // Instruction will succeed + let mut vote_state = VoteState::default(); + vote_state + .increment_credits(current_epoch - MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION as Epoch); + vote_account + .serialize_data(&VoteStateVersions::new_current(vote_state)) + .unwrap(); + process_instruction_deactivate_delinquent( + &stake_address, + &stake_account, + &vote_account, + &reference_vote_account, + Ok(()), + ); + + // `reference_vote_account` has consistently voted and `vote_account` voted once + // `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION` - 1 epochs ago + // Instruction will fail + let mut vote_state = VoteState::default(); + vote_state.increment_credits( + current_epoch - (MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION - 1) as Epoch, + ); + vote_account + .serialize_data(&VoteStateVersions::new_current(vote_state)) + .unwrap(); + process_instruction_deactivate_delinquent( + &stake_address, + &stake_account, + &vote_account, + &reference_vote_account, + Err(StakeError::MinimumDelinquentEpochsForDeactivationNotMet.into()), + ); + } } diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index e6fe343675..4a04e900d4 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -24,6 +24,7 @@ use { config::Config, instruction::{LockupArgs, StakeError}, program::id, + tools::{acceptable_reference_epoch_credits, eligible_for_deactivate_delinquent}, }, stake_history::{StakeHistory, StakeHistoryEntry}, transaction_context::{BorrowedAccount, InstructionContext, TransactionContext}, @@ -128,7 +129,7 @@ fn redelegate( Ok(()) } -fn new_stake( +pub(crate) fn new_stake( stake: u64, voter_pubkey: &Pubkey, vote_state: &VoteState, @@ -862,6 +863,57 @@ pub fn withdraw( Ok(()) } +pub(crate) fn deactivate_delinquent( + transaction_context: &TransactionContext, + instruction_context: &InstructionContext, + stake_account: &mut BorrowedAccount, + delinquent_vote_account_index: usize, + reference_vote_account_index: usize, + current_epoch: Epoch, +) -> Result<(), InstructionError> { + let delinquent_vote_account_pubkey = transaction_context.get_key_of_account_at_index( + instruction_context.get_index_in_transaction(delinquent_vote_account_index)?, + )?; + let delinquent_vote_account = instruction_context + .try_borrow_account(transaction_context, delinquent_vote_account_index)?; + if *delinquent_vote_account.get_owner() != solana_vote_program::id() { + return Err(InstructionError::IncorrectProgramId); + } + let delinquent_vote_state = delinquent_vote_account + .get_state::()? + .convert_to_current(); + + let reference_vote_account = instruction_context + .try_borrow_account(transaction_context, reference_vote_account_index)?; + if *reference_vote_account.get_owner() != solana_vote_program::id() { + return Err(InstructionError::IncorrectProgramId); + } + let reference_vote_state = reference_vote_account + .get_state::()? + .convert_to_current(); + + if !acceptable_reference_epoch_credits(&reference_vote_state.epoch_credits, current_epoch) { + return Err(StakeError::InsufficientReferenceVotes.into()); + } + + if let StakeState::Stake(meta, mut stake) = stake_account.get_state()? { + if stake.delegation.voter_pubkey != *delinquent_vote_account_pubkey { + return Err(StakeError::VoteAddressMismatch.into()); + } + + // 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)?; + stake_account.set_state(&StakeState::Stake(meta, stake)) + } else { + Err(StakeError::MinimumDelinquentEpochsForDeactivationNotMet.into()) + } + } else { + Err(InstructionError::InvalidAccountData) + } +} + /// After calling `validate_delegated_amount()`, this struct contains calculated values that are used /// by the caller. struct ValidatedDelegatedInfo { diff --git a/programs/vote/src/vote_state/mod.rs b/programs/vote/src/vote_state/mod.rs index 4950db096c..a56e4927fa 100644 --- a/programs/vote/src/vote_state/mod.rs +++ b/programs/vote/src/vote_state/mod.rs @@ -316,7 +316,7 @@ pub struct VoteState { /// history of how many credits earned by the end of each epoch /// each tuple is (Epoch, credits, prev_credits) - pub(crate) epoch_credits: Vec<(Epoch, u64, u64)>, + pub epoch_credits: Vec<(Epoch, u64, u64)>, /// most recent timestamp submitted with a vote pub last_timestamp: BlockTimestamp, @@ -1013,7 +1013,7 @@ impl VoteState { self.votes.iter().map(|v| v.slot).collect() } - fn current_epoch(&self) -> Epoch { + pub fn current_epoch(&self) -> Epoch { if self.epoch_credits.is_empty() { 0 } else { diff --git a/sdk/program/src/stake/instruction.rs b/sdk/program/src/stake/instruction.rs index 12715a3e79..c2ee8fcb3a 100644 --- a/sdk/program/src/stake/instruction.rs +++ b/sdk/program/src/stake/instruction.rs @@ -46,6 +46,17 @@ pub enum StakeError { #[error("custodian signature not present")] CustodianSignatureMissing, + + #[error("insufficient voting activity in the reference vote account")] + InsufficientReferenceVotes, + + #[error("stake account is not delegated to the provided vote account")] + VoteAddressMismatch, + + #[error( + "stake account has not been delinquent for the minimum epochs required for deactivation" + )] + MinimumDelinquentEpochsForDeactivationNotMet, } impl DecodeError for StakeError { @@ -234,6 +245,19 @@ pub enum StakeInstruction { /// /// [`get_minimum_delegation()`]: super::tools::get_minimum_delegation GetMinimumDelegation, + + /// Deactivate stake delegated to a vote account that has been delinquent for at least + /// `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION` epochs. + /// + /// No signer is required for this instruction as it is a common good to deactivate abandoned + /// stake. + /// + /// # Account references + /// 0. `[WRITE]` Delegated stake account + /// 1. `[]` Delinquent vote account for the delegated stake account + /// 2. `[]` Reference vote account that has voted at least once in the last + /// `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION` epochs + DeactivateDelinquent, } #[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone, Copy)] @@ -698,6 +722,19 @@ pub fn get_minimum_delegation() -> Instruction { ) } +pub fn deactivate_delinquent_stake( + stake_account: &Pubkey, + delinquent_vote_account: &Pubkey, + reference_vote_account: &Pubkey, +) -> Instruction { + let account_metas = vec![ + AccountMeta::new(*stake_account, false), + AccountMeta::new_readonly(*delinquent_vote_account, false), + AccountMeta::new_readonly(*reference_vote_account, false), + ]; + Instruction::new_with_bincode(id(), &StakeInstruction::DeactivateDelinquent, account_metas) +} + #[cfg(test)] mod tests { use {super::*, crate::instruction::InstructionError}; diff --git a/sdk/program/src/stake/mod.rs b/sdk/program/src/stake/mod.rs index 5366b112b8..bf2d3566a2 100644 --- a/sdk/program/src/stake/mod.rs +++ b/sdk/program/src/stake/mod.rs @@ -10,3 +10,7 @@ pub mod program { // NOTE: This constant will be deprecated soon; if possible, use // `solana_stake_program::get_minimum_delegation()` instead. pub const MINIMUM_STAKE_DELEGATION: u64 = 1; + +/// The minimum number of epochs before stake account that is delegated to a delinquent vote +/// account may be unstaked with `StakeInstruction::DeactivateDelinquent` +pub const MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION: usize = 5; diff --git a/sdk/program/src/stake/tools.rs b/sdk/program/src/stake/tools.rs index 24d5e2d8a9..842a822b0e 100644 --- a/sdk/program/src/stake/tools.rs +++ b/sdk/program/src/stake/tools.rs @@ -1,5 +1,7 @@ //! Utility functions -use crate::program_error::ProgramError; +use crate::{ + clock::Epoch, program_error::ProgramError, stake::MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION, +}; /// Helper function for programs to call [`GetMinimumDelegation`] and then fetch the return data /// @@ -36,3 +38,117 @@ fn get_minimum_delegation_return_data() -> Result { }) .map(u64::from_le_bytes) } + +// Check if the provided `epoch_credits` demonstrate active voting over the previous +// `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION` +pub fn acceptable_reference_epoch_credits( + epoch_credits: &[(Epoch, u64, u64)], + current_epoch: Epoch, +) -> bool { + if let Some(epoch_index) = epoch_credits + .len() + .checked_sub(MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION) + { + let mut epoch = current_epoch; + for (vote_epoch, ..) in epoch_credits[epoch_index..].iter().rev() { + if *vote_epoch != epoch { + return false; + } + epoch = epoch.saturating_sub(1); + } + true + } else { + false + } +} + +// Check if the provided `epoch_credits` demonstrate delinquency over the previous +// `MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION` +pub fn eligible_for_deactivate_delinquent( + epoch_credits: &[(Epoch, u64, u64)], + current_epoch: Epoch, +) -> bool { + match epoch_credits.last() { + None => true, + Some((epoch, ..)) => { + if let Some(minimum_epoch) = + current_epoch.checked_sub(MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION as Epoch) + { + *epoch <= minimum_epoch + } else { + false + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_acceptable_reference_epoch_credits() { + let epoch_credits = []; + assert!(!acceptable_reference_epoch_credits(&epoch_credits, 0)); + + let epoch_credits = [(0, 42, 42), (1, 42, 42), (2, 42, 42), (3, 42, 42)]; + assert!(!acceptable_reference_epoch_credits(&epoch_credits, 3)); + + let epoch_credits = [ + (0, 42, 42), + (1, 42, 42), + (2, 42, 42), + (3, 42, 42), + (4, 42, 42), + ]; + assert!(!acceptable_reference_epoch_credits(&epoch_credits, 3)); + assert!(acceptable_reference_epoch_credits(&epoch_credits, 4)); + + let epoch_credits = [ + (1, 42, 42), + (2, 42, 42), + (3, 42, 42), + (4, 42, 42), + (5, 42, 42), + ]; + assert!(acceptable_reference_epoch_credits(&epoch_credits, 5)); + + let epoch_credits = [ + (0, 42, 42), + (2, 42, 42), + (3, 42, 42), + (4, 42, 42), + (5, 42, 42), + ]; + assert!(!acceptable_reference_epoch_credits(&epoch_credits, 5)); + } + + #[test] + fn test_eligible_for_deactivate_delinquent() { + let epoch_credits = []; + assert!(eligible_for_deactivate_delinquent(&epoch_credits, 42)); + + let epoch_credits = [(0, 42, 42)]; + assert!(!eligible_for_deactivate_delinquent(&epoch_credits, 0)); + + let epoch_credits = [(0, 42, 42)]; + assert!(!eligible_for_deactivate_delinquent( + &epoch_credits, + MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION as Epoch - 1 + )); + assert!(eligible_for_deactivate_delinquent( + &epoch_credits, + MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION as Epoch + )); + + let epoch_credits = [(100, 42, 42)]; + assert!(!eligible_for_deactivate_delinquent( + &epoch_credits, + 100 + MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION as Epoch - 1 + )); + assert!(eligible_for_deactivate_delinquent( + &epoch_credits, + 100 + MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION as Epoch + )); + } +} diff --git a/sdk/src/feature_set.rs b/sdk/src/feature_set.rs index 570e0dda9c..bcf4924cda 100644 --- a/sdk/src/feature_set.rs +++ b/sdk/src/feature_set.rs @@ -271,6 +271,10 @@ pub mod update_syscall_base_costs { solana_sdk::declare_id!("2h63t332mGCCsWK2nqqqHhN4U9ayyqhLVFvczznHDoTZ"); } +pub mod stake_deactivate_delinquent_instruction { + solana_sdk::declare_id!("437r62HoAdUb63amq3D7ENnBLDhHT2xY8eFkLJYVKK4x"); +} + pub mod vote_withdraw_authority_may_change_authorized_voter { solana_sdk::declare_id!("AVZS3ZsN4gi6Rkx2QUibYuSJG3S6QHib7xCYhG6vGJxU"); } @@ -401,6 +405,7 @@ lazy_static! { (require_rent_exempt_accounts::id(), "require all new transaction accounts with data to be rent-exempt"), (filter_votes_outside_slot_hashes::id(), "filter vote slots older than the slot hashes history"), (update_syscall_base_costs::id(), "update syscall base costs"), + (stake_deactivate_delinquent_instruction::id(), "enable the deactivate delinquent stake instruction #23932"), (vote_withdraw_authority_may_change_authorized_voter::id(), "vote account withdraw authority may change the authorized voter #22521"), (spl_associated_token_account_v1_0_4::id(), "SPL Associated Token Account Program release version 1.0.4, tied to token 3.3.0 #22648"), (reject_vote_account_close_unless_zero_credit_epoch::id(), "fail vote account withdraw to 0 unless account earned 0 credits in last completed epoch"), diff --git a/transaction-status/src/parse_stake.rs b/transaction-status/src/parse_stake.rs index 28e8460ea7..169aa04869 100644 --- a/transaction-status/src/parse_stake.rs +++ b/transaction-status/src/parse_stake.rs @@ -273,6 +273,17 @@ pub fn parse_stake( instruction_type: "getMinimumDelegation".to_string(), info: Value::default(), }), + StakeInstruction::DeactivateDelinquent => { + check_num_stake_accounts(&instruction.accounts, 3)?; + Ok(ParsedInstructionEnum { + instruction_type: "deactivateDeactive".to_string(), + info: json!({ + "stakeAccount": account_keys[instruction.accounts[0] as usize].to_string(), + "voteAccount": account_keys[instruction.accounts[1] as usize].to_string(), + "referenceVoteAccount": account_keys[instruction.accounts[3] as usize].to_string(), + }), + }) + } } }