From e4bfa33a0c440af5b83eb9f3daac114df8ef5eec Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Fri, 17 May 2024 12:44:03 -0700 Subject: [PATCH 01/11] implement both instructions --- programs/stake/src/stake_instruction.rs | 51 ++++- programs/stake/src/stake_state.rs | 263 ++++++++++++++++++++++++ sdk/program/src/stake/instruction.rs | 88 ++++++++ transaction-status/src/parse_stake.rs | 1 + 4 files changed, 402 insertions(+), 1 deletion(-) diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index 203dcebe68c462..c9ef1d1b0b55e2 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -1,7 +1,8 @@ use { crate::stake_state::{ authorize, authorize_with_seed, deactivate, deactivate_delinquent, delegate, initialize, - merge, new_warmup_cooldown_rate_epoch, redelegate, set_lockup, split, withdraw, + merge, move_lamports, move_stake, new_warmup_cooldown_rate_epoch, redelegate, set_lockup, + split, withdraw, }, log::*, solana_program_runtime::{ @@ -352,6 +353,54 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| Err(InstructionError::InvalidInstructionData) } } + StakeInstruction::MoveStake(lamports) => { + let me = get_stake_account()?; + instruction_context.check_number_of_instruction_accounts(2)?; + let clock = + get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; + let stake_history = get_sysvar_with_account_check::stake_history( + invoke_context, + instruction_context, + 3, + )?; + instruction_context.check_number_of_instruction_accounts(5)?; + drop(me); + move_stake( + invoke_context, + transaction_context, + instruction_context, + 0, + lamports, + 1, + &clock, + &stake_history, + 4, + ) + } + StakeInstruction::MoveLamports(lamports) => { + let me = get_stake_account()?; + instruction_context.check_number_of_instruction_accounts(2)?; + let clock = + get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; + let stake_history = get_sysvar_with_account_check::stake_history( + invoke_context, + instruction_context, + 3, + )?; + instruction_context.check_number_of_instruction_accounts(5)?; + drop(me); + move_lamports( + invoke_context, + transaction_context, + instruction_context, + 0, + lamports, + 1, + &clock, + &stake_history, + 4, + ) + } } }); diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index d3ee57beca43f2..cf7e0047c09b26 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -132,6 +132,85 @@ fn redelegate_stake( Ok(()) } +fn move_stake_or_lamports_shared_checks( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + instruction_context: &InstructionContext, + source_account: &BorrowedAccount, + lamports: u64, + destination_account: &BorrowedAccount, + clock: &Clock, + stake_history: &StakeHistory, + stake_authority_index: IndexOfAccount, +) -> Result<(MergeKind, MergeKind), InstructionError> { + // authority must sign + let stake_authority_pubkey = transaction_context.get_key_of_account_at_index( + instruction_context + .get_index_of_instruction_account_in_transaction(stake_authority_index)?, + )?; + if !instruction_context.is_instruction_account_signer(stake_authority_index)? { + return Err(InstructionError::MissingRequiredSignature); + } + + let mut signers = HashSet::new(); + signers.insert(*stake_authority_pubkey); + + // check owners + if *source_account.get_owner() != id() || *destination_account.get_owner() != id() { + return Err(InstructionError::IncorrectProgramId); + } + + // confirm not the same account + if *source_account.get_key() == *destination_account.get_key() { + return Err(InstructionError::InvalidInstructionData); + } + + // source and destination must be writable + if !source_account.is_writable() || !destination_account.is_writable() { + return Err(InstructionError::InvalidInstructionData); + } + + // must move something + if lamports == 0 { + return Err(InstructionError::InvalidArgument); + } + + // get_if_mergeable ensures accounts are not partly activated or in any form of deactivating + // we still need to exclude activating state ourselves + let source_merge_kind = MergeKind::get_if_mergeable( + invoke_context, + &source_account.get_state()?, + source_account.get_lamports(), + clock, + stake_history, + )?; + + // Authorized staker is allowed to move stake + source_merge_kind + .meta() + .authorized + .check(&signers, StakeAuthorize::Staker)?; + + // same transient assurance as with source + let destination_merge_kind = MergeKind::get_if_mergeable( + invoke_context, + &destination_account.get_state()?, + destination_account.get_lamports(), + clock, + stake_history, + )?; + + // ensure all authorities match and lockups match if lockup is in force + MergeKind::metas_can_merge( + invoke_context, + source_merge_kind.meta(), + destination_merge_kind.meta(), + clock, + )?; + + Ok((source_merge_kind, destination_merge_kind)) +} + pub(crate) fn new_stake( stake: u64, voter_pubkey: &Pubkey, @@ -705,6 +784,190 @@ pub fn redelegate( Ok(()) } +pub fn move_stake( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + instruction_context: &InstructionContext, + source_account_index: IndexOfAccount, + lamports: u64, + destination_account_index: IndexOfAccount, + clock: &Clock, + stake_history: &StakeHistory, + stake_authority_index: IndexOfAccount, +) -> Result<(), InstructionError> { + let mut source_account = instruction_context + .try_borrow_instruction_account(transaction_context, source_account_index)?; + + let mut destination_account = instruction_context + .try_borrow_instruction_account(transaction_context, destination_account_index)?; + + let (source_merge_kind, destination_merge_kind) = move_stake_or_lamports_shared_checks( + invoke_context, + transaction_context, + instruction_context, + &source_account, + lamports, + &destination_account, + clock, + stake_history, + stake_authority_index, + )?; + + // source and destination will have their data reassigned + if source_account.get_data().len() != StakeStateV2::size_of() + || destination_account.get_data().len() != StakeStateV2::size_of() + { + return Err(InstructionError::InvalidAccountData); + } + + // source must be fully active + // destination must not be activating + // if active, destination must be delegated to the same vote account as source + // minimum delegations must be respected for any accounts that become/remain active + match source_merge_kind { + MergeKind::FullyActive(source_meta, mut source_stake) => { + let minimum_delegation = + crate::get_minimum_delegation(invoke_context.get_feature_set()); + + let source_effective_stake = source_stake.delegation.stake; + let source_final_stake = source_effective_stake + .checked_sub(lamports) + .ok_or(InstructionError::InvalidArgument)?; + + if source_final_stake != 0 && source_final_stake < minimum_delegation { + return Err(InstructionError::InvalidArgument); + } + + let destination_meta = match destination_merge_kind { + MergeKind::FullyActive(destination_meta, mut destination_stake) => { + if source_stake.delegation.voter_pubkey + != destination_stake.delegation.voter_pubkey + { + return Err(StakeError::VoteAddressMismatch.into()); + } + + let destination_effective_stake = destination_stake.delegation.stake; + let destination_final_stake = destination_effective_stake + .checked_add(lamports) + .ok_or(InstructionError::ArithmeticOverflow)?; + + if destination_final_stake < minimum_delegation { + return Err(InstructionError::InvalidArgument); + } + + merge_delegation_stake_and_credits_observed( + &mut destination_stake, + lamports, + source_stake.credits_observed, + )?; + + destination_account.set_state(&StakeStateV2::Stake( + destination_meta, + destination_stake, + StakeFlags::empty(), + ))?; + + destination_meta + } + MergeKind::Inactive(destination_meta, _, _) => { + if lamports < minimum_delegation { + return Err(InstructionError::InvalidArgument); + } + + let mut destination_stake = source_stake; + destination_stake.delegation.stake = lamports; + destination_account.set_state(&StakeStateV2::Stake( + destination_meta, + destination_stake, + StakeFlags::empty(), + ))?; + + destination_meta + } + _ => return Err(InstructionError::InvalidAccountData), + }; + + if source_final_stake == 0 { + source_account.set_state(&StakeStateV2::Initialized(source_meta))?; + } else { + source_stake.delegation.stake = source_final_stake; + source_account.set_state(&StakeStateV2::Stake( + source_meta, + source_stake, + StakeFlags::empty(), + ))?; + } + + source_account.checked_sub_lamports(lamports)?; + destination_account.checked_add_lamports(lamports)?; + + // this should be impossible, but because we do all our math with delegations, best to guard it + if source_account.get_lamports() < source_meta.rent_exempt_reserve + || destination_account.get_lamports() < destination_meta.rent_exempt_reserve + { + ic_msg!( + invoke_context, + "Delegation calculations violated lamport balance assumptions" + ); + return Err(InstructionError::InvalidArgument); + } + } + _ => return Err(InstructionError::InvalidAccountData), + } + + Ok(()) +} + +pub fn move_lamports( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + instruction_context: &InstructionContext, + source_account_index: IndexOfAccount, + lamports: u64, + destination_account_index: IndexOfAccount, + clock: &Clock, + stake_history: &StakeHistory, + stake_authority_index: IndexOfAccount, +) -> Result<(), InstructionError> { + let mut source_account = instruction_context + .try_borrow_instruction_account(transaction_context, source_account_index)?; + + let mut destination_account = instruction_context + .try_borrow_instruction_account(transaction_context, destination_account_index)?; + + let (source_merge_kind, _) = move_stake_or_lamports_shared_checks( + invoke_context, + transaction_context, + instruction_context, + &source_account, + lamports, + &destination_account, + clock, + stake_history, + stake_authority_index, + )?; + + let source_free_lamports = match source_merge_kind { + MergeKind::FullyActive(source_meta, source_stake) => source_account + .get_lamports() + .saturating_sub(source_stake.delegation.stake) + .saturating_sub(source_meta.rent_exempt_reserve), + MergeKind::Inactive(source_meta, source_lamports, _) => { + source_lamports.saturating_sub(source_meta.rent_exempt_reserve) + } + _ => return Err(InstructionError::InvalidAccountData), + }; + + if lamports > source_free_lamports { + return Err(InstructionError::InvalidArgument); + } + + source_account.checked_sub_lamports(lamports)?; + destination_account.checked_add_lamports(lamports)?; + + 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 ec929864ffd6b0..cfa063ba80f58b 100644 --- a/sdk/program/src/stake/instruction.rs +++ b/sdk/program/src/stake/instruction.rs @@ -307,6 +307,46 @@ pub enum StakeInstruction { /// 4. `[SIGNER]` Stake authority /// Redelegate, + + /// Move stake between accounts with the same authorities and lockups, using Staker authority. + /// + /// The source account must be fully active. If its entire delegation is moved, it immediately + /// becomes inactive. Otherwise, at least the minimum delegation of active stake must remain. + /// + /// The destination account must be fully active or fully inactive. If it is active, it must + /// be delegated to the same vote accouunt as the source. If it is inactive, it + /// immediately becomes active, and must contain at least the minimum delegation. The + /// destination must be pre-funded with the rent-exempt reserve. + /// + /// This instruction only affects or moves active stake. Additional unstaked lamports are never + /// moved, activated, or deactivated, and accounts are never deallocated. + /// + /// # Account references + /// 0. `[WRITE]` Active source stake account + /// 1. `[WRITE]` Active or inactive destination stake account + /// 2. `[]` Clock sysvar + /// 3. `[]` Stake history sysvar that carries stake warmup/cooldown history + /// 4. `[SIGNER]` Stake authority + /// + /// The u64 is the portion of the stake to move, which may be the entire delegation + MoveStake(u64), + + /// Move unstaked lamports between accounts with the same authorities and lockups, using Staker + /// authority. + /// + /// The source account must be fully active or fully inactive. The destination may be in any + /// mergeable state (active, inactive, or activating, but not in warmup cooldown). Only lamports that + /// are neither backing a delegation nor required for rent-exemption may be moved. + /// + /// # Account references + /// 0. `[WRITE]` Active or inactive source stake account + /// 1. `[WRITE]` Mergeable destination stake account + /// 2. `[]` Clock sysvar + /// 3. `[]` Stake history sysvar that carries stake warmup/cooldown history + /// 4. `[SIGNER]` Stake authority + /// + /// The u64 is the portion of available lamports to move + MoveLamports(u64), } #[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)] @@ -847,6 +887,54 @@ pub fn redelegate_with_seed( ] } +pub fn move_stake( + source_stake_pubkey: &Pubkey, + destination_stake_pubkey: &Pubkey, + authorized_pubkey: &Pubkey, + lamports: u64, +) -> Instruction { + move_stake_or_lamports( + source_stake_pubkey, + destination_stake_pubkey, + authorized_pubkey, + lamports, + &(StakeInstruction::MoveStake as fn(u64) -> StakeInstruction), + ) +} + +pub fn move_lamports( + source_stake_pubkey: &Pubkey, + destination_stake_pubkey: &Pubkey, + authorized_pubkey: &Pubkey, + lamports: u64, +) -> Instruction { + move_stake_or_lamports( + source_stake_pubkey, + destination_stake_pubkey, + authorized_pubkey, + lamports, + &(StakeInstruction::MoveLamports as fn(u64) -> StakeInstruction), + ) +} + +fn move_stake_or_lamports( + source_stake_pubkey: &Pubkey, + destination_stake_pubkey: &Pubkey, + authorized_pubkey: &Pubkey, + lamports: u64, + value_constructor: &fn(u64) -> StakeInstruction, +) -> Instruction { + let account_metas = vec![ + AccountMeta::new(*source_stake_pubkey, false), + AccountMeta::new(*destination_stake_pubkey, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(*authorized_pubkey, true), + ]; + + Instruction::new_with_bincode(id(), &value_constructor(lamports), account_metas) +} + #[cfg(test)] mod tests { use {super::*, crate::instruction::InstructionError}; diff --git a/transaction-status/src/parse_stake.rs b/transaction-status/src/parse_stake.rs index 8993a3eb57f95d..9285fa7dc6b4b2 100644 --- a/transaction-status/src/parse_stake.rs +++ b/transaction-status/src/parse_stake.rs @@ -297,6 +297,7 @@ pub fn parse_stake( }), }) } + StakeInstruction::MoveStake(_) | StakeInstruction::MoveLamports(_) => todo!(), } } From 30ae6cee40b19cd2f40bc8ace71a0bbcdfbe60b4 Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Mon, 3 Jun 2024 15:45:31 -0700 Subject: [PATCH 02/11] instruction parsers --- transaction-status/src/parse_stake.rs | 80 ++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/transaction-status/src/parse_stake.rs b/transaction-status/src/parse_stake.rs index 9285fa7dc6b4b2..c39f1b2c638cad 100644 --- a/transaction-status/src/parse_stake.rs +++ b/transaction-status/src/parse_stake.rs @@ -297,7 +297,34 @@ pub fn parse_stake( }), }) } - StakeInstruction::MoveStake(_) | StakeInstruction::MoveLamports(_) => todo!(), + StakeInstruction::MoveStake(lamports) => { + check_num_stake_accounts(&instruction.accounts, 5)?; + Ok(ParsedInstructionEnum { + instruction_type: "moveStake".to_string(), + info: json!({ + "source": account_keys[instruction.accounts[0] as usize].to_string(), + "destination": account_keys[instruction.accounts[1] as usize].to_string(), + "clockSysvar": account_keys[instruction.accounts[2] as usize].to_string(), + "stakeHistorySysvar": account_keys[instruction.accounts[3] as usize].to_string(), + "stakeAuthority": account_keys[instruction.accounts[4] as usize].to_string(), + "lamports": lamports, + }), + }) + } + StakeInstruction::MoveLamports(lamports) => { + check_num_stake_accounts(&instruction.accounts, 5)?; + Ok(ParsedInstructionEnum { + instruction_type: "moveLamports".to_string(), + info: json!({ + "source": account_keys[instruction.accounts[0] as usize].to_string(), + "destination": account_keys[instruction.accounts[1] as usize].to_string(), + "clockSysvar": account_keys[instruction.accounts[2] as usize].to_string(), + "stakeHistorySysvar": account_keys[instruction.accounts[3] as usize].to_string(), + "stakeAuthority": account_keys[instruction.accounts[4] as usize].to_string(), + "lamports": lamports, + }), + }) + } } } @@ -310,6 +337,7 @@ mod test { use { super::*, solana_sdk::{ + instruction::Instruction, message::Message, pubkey::Pubkey, stake::{ @@ -1158,4 +1186,54 @@ mod test { message.instructions[0].accounts.pop(); assert!(parse_stake(&message.instructions[0], &AccountKeys::new(&keys, None)).is_err()); } + + #[test] + fn test_parse_stake_move_ix() { + let source_stake_pubkey = Pubkey::new_unique(); + let destination_stake_pubkey = Pubkey::new_unique(); + let authorized_pubkey = Pubkey::new_unique(); + let lamports = 1_000_000; + + type InstructionFn = fn(&Pubkey, &Pubkey, &Pubkey, u64) -> Instruction; + let test_vectors: Vec<(InstructionFn, String)> = vec![ + (instruction::move_stake, "moveStake".to_string()), + (instruction::move_lamports, "moveLamports".to_string()), + ]; + + for (mk_ixn, ixn_string) in test_vectors { + let instruction = mk_ixn( + &source_stake_pubkey, + &destination_stake_pubkey, + &authorized_pubkey, + lamports, + ); + let mut message = Message::new(&[instruction], None); + assert_eq!( + parse_stake( + &message.instructions[0], + &AccountKeys::new(&message.account_keys, None) + ) + .unwrap(), + ParsedInstructionEnum { + instruction_type: ixn_string, + info: json!({ + "source": source_stake_pubkey.to_string(), + "destination": destination_stake_pubkey.to_string(), + "clockSysvar": sysvar::clock::ID.to_string(), + "stakeHistorySysvar": sysvar::stake_history::ID.to_string(), + "stakeAuthority": authorized_pubkey.to_string(), + "lamports": lamports, + }), + } + ); + assert!(parse_stake( + &message.instructions[0], + &AccountKeys::new(&message.account_keys[0..4], None) + ) + .is_err()); + let keys = message.account_keys.clone(); + message.instructions[0].accounts.pop(); + assert!(parse_stake(&message.instructions[0], &AccountKeys::new(&keys, None)).is_err()); + } + } } From 3ceeae2328b93138bc0e4f4ded70e75744d0e01b Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Mon, 3 Jun 2024 16:38:12 -0700 Subject: [PATCH 03/11] add move to omnibus instruction tests --- programs/stake/src/stake_instruction.rs | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index c9ef1d1b0b55e2..270f5f13ef8932 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -720,6 +720,26 @@ mod tests { ), Err(InstructionError::InvalidAccountData), ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_stake( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(InstructionError::InvalidAccountData), + ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_lamports( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(InstructionError::InvalidAccountData), + ); } #[test_case(feature_set_no_minimum_delegation(); "no_min_delegation")] @@ -848,6 +868,26 @@ mod tests { )[2], Err(InstructionError::InvalidAccountOwner), ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_stake( + &spoofed_stake_state_pubkey(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(InstructionError::InvalidAccountOwner), + ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_lamports( + &spoofed_stake_state_pubkey(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(InstructionError::InvalidAccountOwner), + ); } #[test_case(feature_set_no_minimum_delegation(); "no_min_delegation")] @@ -8079,6 +8119,26 @@ mod tests { )[2], Err(StakeError::EpochRewardsActive.into()), ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_stake( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(StakeError::EpochRewardsActive.into()), + ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_lamports( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(StakeError::EpochRewardsActive.into()), + ); // Only GetMinimumDelegation should not return StakeError::EpochRewardsActive process_instruction_as_one_arg( From b39c7e3861d0e55028534835954f8ca086143872 Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Tue, 4 Jun 2024 13:44:56 -0700 Subject: [PATCH 04/11] add feature gate --- programs/stake/src/stake_instruction.rs | 98 ++++++++++++++----------- sdk/src/feature_set.rs | 6 ++ 2 files changed, 62 insertions(+), 42 deletions(-) diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index 270f5f13ef8932..e6cf2a2320c22e 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -355,51 +355,65 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| } StakeInstruction::MoveStake(lamports) => { let me = get_stake_account()?; - instruction_context.check_number_of_instruction_accounts(2)?; - let clock = - get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; - let stake_history = get_sysvar_with_account_check::stake_history( - invoke_context, - instruction_context, - 3, - )?; - instruction_context.check_number_of_instruction_accounts(5)?; - drop(me); - move_stake( - invoke_context, - transaction_context, - instruction_context, - 0, - lamports, - 1, - &clock, - &stake_history, - 4, - ) + if invoke_context + .get_feature_set() + .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) + { + instruction_context.check_number_of_instruction_accounts(2)?; + let clock = + get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; + let stake_history = get_sysvar_with_account_check::stake_history( + invoke_context, + instruction_context, + 3, + )?; + instruction_context.check_number_of_instruction_accounts(5)?; + drop(me); + move_stake( + invoke_context, + transaction_context, + instruction_context, + 0, + lamports, + 1, + &clock, + &stake_history, + 4, + ) + } else { + Err(InstructionError::InvalidInstructionData) + } } StakeInstruction::MoveLamports(lamports) => { let me = get_stake_account()?; - instruction_context.check_number_of_instruction_accounts(2)?; - let clock = - get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; - let stake_history = get_sysvar_with_account_check::stake_history( - invoke_context, - instruction_context, - 3, - )?; - instruction_context.check_number_of_instruction_accounts(5)?; - drop(me); - move_lamports( - invoke_context, - transaction_context, - instruction_context, - 0, - lamports, - 1, - &clock, - &stake_history, - 4, - ) + if invoke_context + .get_feature_set() + .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) + { + instruction_context.check_number_of_instruction_accounts(2)?; + let clock = + get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; + let stake_history = get_sysvar_with_account_check::stake_history( + invoke_context, + instruction_context, + 3, + )?; + instruction_context.check_number_of_instruction_accounts(5)?; + drop(me); + move_lamports( + invoke_context, + transaction_context, + instruction_context, + 0, + lamports, + 1, + &clock, + &stake_history, + 4, + ) + } else { + Err(InstructionError::InvalidInstructionData) + } } } }); diff --git a/sdk/src/feature_set.rs b/sdk/src/feature_set.rs index 3704d273bb1c55..a2732f37e1e526 100644 --- a/sdk/src/feature_set.rs +++ b/sdk/src/feature_set.rs @@ -820,6 +820,7 @@ pub mod migrate_config_program_to_core_bpf { pub mod enable_get_epoch_stake_syscall { solana_sdk::declare_id!("7mScTYkJXsbdrcwTQRs7oeCSXoJm4WjzBsRyf8bCU3Np"); } + pub mod migrate_address_lookup_table_program_to_core_bpf { solana_sdk::declare_id!("C97eKZygrkU4JxJsZdjgbUY7iQR7rKTr4NyDWo2E5pRm"); } @@ -832,6 +833,10 @@ pub mod verify_retransmitter_signature { solana_sdk::declare_id!("BZ5g4hRbu5hLQQBdPyo2z9icGyJ8Khiyj3QS6dhWijTb"); } +pub mod move_stake_and_move_lamports_ixs { + solana_sdk::declare_id!("7bTK6Jis8Xpfrs8ZoUfiMDPazTcdPcTWheZFJTA5Z6X4"); +} + lazy_static! { /// Map of feature identifiers to user-visible description pub static ref FEATURE_NAMES: HashMap = [ @@ -1035,6 +1040,7 @@ lazy_static! { (migrate_address_lookup_table_program_to_core_bpf::id(), "Migrate Address Lookup Table program to Core BPF #1651"), (zk_elgamal_proof_program_enabled::id(), "Enable ZkElGamalProof program SIMD-0153"), (verify_retransmitter_signature::id(), "Verify retransmitter signature #1840"), + (move_stake_and_move_lamports_ixs::id(), "Enable MoveStake and MoveLamports stake program instructions #1610"), /*************** ADD NEW FEATURES HERE ***************/ ] .iter() From 8c7a1e73c9f1605e0e4cf76e1b23e60058d365c8 Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Tue, 4 Jun 2024 14:10:58 -0700 Subject: [PATCH 05/11] port tests from neostake --- Cargo.lock | 13 + Cargo.toml | 1 + programs/stake-tests/Cargo.toml | 26 + .../tests/test_move_stake_and_lamports.rs | 1281 +++++++++++++++++ 4 files changed, 1321 insertions(+) create mode 100644 programs/stake-tests/Cargo.toml create mode 100644 programs/stake-tests/tests/test_move_stake_and_lamports.rs diff --git a/Cargo.lock b/Cargo.lock index a72dccdb5ff780..2363eadc7e715a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7366,6 +7366,19 @@ dependencies = [ "test-case", ] +[[package]] +name = "solana-stake-program-tests" +version = "2.0.0" +dependencies = [ + "assert_matches", + "bincode", + "rustc_version 0.4.0", + "solana-program-test", + "solana-sdk", + "solana-vote-program", + "test-case", +] + [[package]] name = "solana-storage-bigtable" version = "2.1.0" diff --git a/Cargo.toml b/Cargo.toml index 34560bd8d75ab5..f2c012f181dd6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ members = [ "programs/ed25519-tests", "programs/loader-v4", "programs/stake", + "programs/stake-tests", "programs/system", "programs/vote", "programs/zk-elgamal-proof", diff --git a/programs/stake-tests/Cargo.toml b/programs/stake-tests/Cargo.toml new file mode 100644 index 00000000000000..4c89f4dd6d0fbf --- /dev/null +++ b/programs/stake-tests/Cargo.toml @@ -0,0 +1,26 @@ +# This package only exists to avoid circular dependencies during cargo publish: +# solana-program-test <--> solana-stake-program + +[package] +name = "solana-stake-program-tests" +publish = false +version = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +bincode = { workspace = true } +solana-program-test = { workspace = true } +solana-sdk = { workspace = true } +solana-vote-program = { workspace = true } +test-case = { workspace = true } + +[build-dependencies] +rustc_version = { workspace = true } + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/programs/stake-tests/tests/test_move_stake_and_lamports.rs b/programs/stake-tests/tests/test_move_stake_and_lamports.rs new file mode 100644 index 00000000000000..7c3a66f4e46f2d --- /dev/null +++ b/programs/stake-tests/tests/test_move_stake_and_lamports.rs @@ -0,0 +1,1281 @@ +#![allow(clippy::arithmetic_side_effects)] + +// NOTE this is temporarily ported from the bpf stake program repo so MoveStake and MoveLamports can be tested comprehensively +// in the future we will either port *all* instruction tests from bpf stake program and remove existing stakeinstruction tests +// or we will develop a text fixture system that allows fuzzing and obsoletes both existing test suites +// in other words the utility functions in this file should not be broken out into modules or used elsewhere + +use { + solana_program_test::*, + solana_sdk::{ + account::Account as SolanaAccount, + entrypoint::ProgramResult, + feature_set::move_stake_and_move_lamports_ixs, + instruction::Instruction, + program_error::ProgramError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + signers::Signers, + stake::{ + self, + instruction::{self as ixn, StakeError}, + program as stake_program, + state::{Authorized, Lockup, Meta, Stake, StakeStateV2}, + }, + system_instruction, system_program, + sysvar::{clock::Clock, stake_history::StakeHistory}, + transaction::{Transaction, TransactionError}, + }, + solana_vote_program::{ + self, vote_instruction, + vote_state::{VoteInit, VoteState, VoteStateVersions}, + }, + test_case::test_matrix, +}; + +const NO_SIGNERS: &[Keypair] = &[]; + +fn program_test() -> ProgramTest { + program_test_without_features(&[]) +} + +fn program_test_without_features(feature_ids: &[Pubkey]) -> ProgramTest { + let mut program_test = ProgramTest::default(); + for feature_id in feature_ids { + program_test.deactivate_feature(*feature_id); + } + + program_test +} + +#[derive(Debug, PartialEq)] +struct Accounts { + validator: Keypair, + voter: Keypair, + withdrawer: Keypair, + vote_account: Keypair, +} + +impl Accounts { + async fn initialize(&self, context: &mut ProgramTestContext) { + let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + create_vote( + context, + &self.validator, + &self.voter.pubkey(), + &self.withdrawer.pubkey(), + &self.vote_account, + ) + .await; + } +} + +impl Default for Accounts { + fn default() -> Self { + let vote_account = Keypair::new(); + + Self { + validator: Keypair::new(), + voter: Keypair::new(), + withdrawer: Keypair::new(), + vote_account, + } + } +} + +async fn create_vote( + context: &mut ProgramTestContext, + validator: &Keypair, + voter: &Pubkey, + withdrawer: &Pubkey, + vote_account: &Keypair, +) { + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_voter = rent.minimum_balance(VoteState::size_of()); + + let mut instructions = vec![system_instruction::create_account( + &context.payer.pubkey(), + &validator.pubkey(), + rent.minimum_balance(0), + 0, + &system_program::id(), + )]; + instructions.append(&mut vote_instruction::create_account_with_config( + &context.payer.pubkey(), + &vote_account.pubkey(), + &VoteInit { + node_pubkey: validator.pubkey(), + authorized_voter: *voter, + authorized_withdrawer: *withdrawer, + ..VoteInit::default() + }, + rent_voter, + vote_instruction::CreateVoteAccountConfig { + space: VoteStateVersions::vote_state_size_of(true) as u64, + ..Default::default() + }, + )); + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[validator, vote_account, &context.payer], + context.last_blockhash, + ); + + // ignore errors for idempotency + let _ = context.banks_client.process_transaction(transaction).await; +} + +async fn transfer(context: &mut ProgramTestContext, recipient: &Pubkey, amount: u64) { + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::transfer( + &context.payer.pubkey(), + recipient, + amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} + +async fn advance_epoch(context: &mut ProgramTestContext) { + refresh_blockhash(context).await; + + let root_slot = context.banks_client.get_root_slot().await.unwrap(); + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + context.warp_to_slot(root_slot + slots_per_epoch).unwrap(); +} + +async fn refresh_blockhash(context: &mut ProgramTestContext) { + context.last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); +} + +async fn get_account(banks_client: &mut BanksClient, pubkey: &Pubkey) -> SolanaAccount { + banks_client + .get_account(*pubkey) + .await + .expect("client error") + .expect("account not found") +} + +async fn get_stake_account( + banks_client: &mut BanksClient, + pubkey: &Pubkey, +) -> (Meta, Option, u64) { + let stake_account = get_account(banks_client, pubkey).await; + let lamports = stake_account.lamports; + match bincode::deserialize::(&stake_account.data).unwrap() { + StakeStateV2::Initialized(meta) => (meta, None, lamports), + StakeStateV2::Stake(meta, stake, _) => (meta, Some(stake), lamports), + StakeStateV2::Uninitialized => panic!("panic: uninitialized"), + _ => unimplemented!(), + } +} + +async fn get_stake_account_rent(banks_client: &mut BanksClient) -> u64 { + let rent = banks_client.get_rent().await.unwrap(); + rent.minimum_balance(std::mem::size_of::()) +} + +async fn get_effective_stake(banks_client: &mut BanksClient, pubkey: &Pubkey) -> u64 { + let clock = banks_client.get_sysvar::().await.unwrap(); + let stake_history = banks_client.get_sysvar::().await.unwrap(); + let stake_account = get_account(banks_client, pubkey).await; + match bincode::deserialize::(&stake_account.data).unwrap() { + StakeStateV2::Stake(_, stake, _) => { + stake + .delegation + .stake_activating_and_deactivating(clock.epoch, &stake_history, Some(0)) + .effective + } + _ => 0, + } +} + +async fn get_minimum_delegation(context: &mut ProgramTestContext) -> u64 { + let transaction = Transaction::new_signed_with_payer( + &[stake::instruction::get_minimum_delegation()], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let mut data = context + .banks_client + .simulate_transaction(transaction) + .await + .unwrap() + .simulation_details + .unwrap() + .return_data + .unwrap() + .data; + data.resize(8, 0); + + data.try_into().map(u64::from_le_bytes).unwrap() +} + +async fn create_blank_stake_account_from_keypair( + context: &mut ProgramTestContext, + stake: &Keypair, +) -> Pubkey { + let lamports = get_stake_account_rent(&mut context.banks_client).await; + + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::create_account( + &context.payer.pubkey(), + &stake.pubkey(), + lamports, + StakeStateV2::size_of() as u64, + &stake_program::id(), + )], + Some(&context.payer.pubkey()), + &[&context.payer, stake], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + stake.pubkey() +} + +async fn process_instruction( + context: &mut ProgramTestContext, + instruction: &Instruction, + additional_signers: &T, +) -> ProgramResult { + let mut transaction = + Transaction::new_with_payer(&[instruction.clone()], Some(&context.payer.pubkey())); + + transaction.partial_sign(&[&context.payer], context.last_blockhash); + transaction.sign(additional_signers, context.last_blockhash); + + match context.banks_client.process_transaction(transaction).await { + Ok(_) => Ok(()), + Err(e) => { + // banks client error -> transaction error -> instruction error -> program error + match e.unwrap() { + TransactionError::InstructionError(_, e) => Err(e.try_into().unwrap()), + TransactionError::InsufficientFundsForRent { .. } => { + Err(ProgramError::InsufficientFunds) + } + _ => panic!("couldnt convert {:?} to ProgramError", e), + } + } + } +} + +async fn test_instruction_with_missing_signers( + context: &mut ProgramTestContext, + instruction: &Instruction, + additional_signers: &Vec<&Keypair>, +) { + // remove every signer one by one and ensure we always fail + for i in 0..instruction.accounts.len() { + if instruction.accounts[i].is_signer { + let mut instruction = instruction.clone(); + instruction.accounts[i].is_signer = false; + let reduced_signers: Vec<_> = additional_signers + .iter() + .filter(|s| s.pubkey() != instruction.accounts[i].pubkey) + .collect(); + + let e = process_instruction(context, &instruction, &reduced_signers) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::MissingRequiredSignature); + } + } + + // now make sure the instruction succeeds + process_instruction(context, instruction, additional_signers) + .await + .unwrap(); +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum StakeLifecycle { + Uninitialized = 0, + Initialized, + Activating, + Active, + Deactivating, + Deactive, +} +impl StakeLifecycle { + // (stake, staker, withdrawer) + async fn new_stake_account( + self, + context: &mut ProgramTestContext, + vote_account: &Pubkey, + staked_amount: u64, + ) -> (Keypair, Keypair, Keypair) { + let stake_keypair = Keypair::new(); + let staker_keypair = Keypair::new(); + let withdrawer_keypair = Keypair::new(); + + self.new_stake_account_fully_specified( + context, + vote_account, + staked_amount, + &stake_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + + (stake_keypair, staker_keypair, withdrawer_keypair) + } + + #[allow(clippy::too_many_arguments)] + async fn new_stake_account_fully_specified( + self, + context: &mut ProgramTestContext, + vote_account: &Pubkey, + staked_amount: u64, + stake_keypair: &Keypair, + staker_keypair: &Keypair, + withdrawer_keypair: &Keypair, + lockup: &Lockup, + ) { + let authorized = Authorized { + staker: staker_keypair.pubkey(), + withdrawer: withdrawer_keypair.pubkey(), + }; + + let stake = create_blank_stake_account_from_keypair(context, stake_keypair).await; + if staked_amount > 0 { + transfer(context, &stake, staked_amount).await; + } + + if self >= StakeLifecycle::Initialized { + let instruction = ixn::initialize(&stake, &authorized, lockup); + process_instruction(context, &instruction, NO_SIGNERS) + .await + .unwrap(); + } + + if self >= StakeLifecycle::Activating { + let instruction = ixn::delegate_stake(&stake, &staker_keypair.pubkey(), vote_account); + process_instruction(context, &instruction, &vec![staker_keypair]) + .await + .unwrap(); + } + + if self >= StakeLifecycle::Active { + advance_epoch(context).await; + assert_eq!( + get_effective_stake(&mut context.banks_client, &stake).await, + staked_amount, + ); + } + + if self >= StakeLifecycle::Deactivating { + let instruction = ixn::deactivate_stake(&stake, &staker_keypair.pubkey()); + process_instruction(context, &instruction, &vec![staker_keypair]) + .await + .unwrap(); + } + + if self == StakeLifecycle::Deactive { + advance_epoch(context).await; + assert_eq!( + get_effective_stake(&mut context.banks_client, &stake).await, + 0, + ); + } + } +} + +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, + StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, + StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [false, true], + [false, true] +)] +#[tokio::test] +async fn test_move_stake( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + full_move: bool, + has_lockup: bool, +) { + let mut context = program_test().start_with_context().await; + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let rent_exempt_reserve = get_stake_account_rent(&mut context.banks_client).await; + let minimum_delegation = get_minimum_delegation(&mut context).await; + + // source has 2x minimum so we can easily test an unfunded destination + let source_staked_amount = minimum_delegation * 2; + + // this is the amount of *staked* lamports for test checks + // destinations may have excess lamports but these are *never* activated by move + let dest_staked_amount = if move_dest_type == StakeLifecycle::Active { + minimum_delegation + } else { + 0 + }; + + // test with and without lockup. both of these cases pass, we test failures elsewhere + let lockup = if has_lockup { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + let lockup = Lockup { + unix_timestamp: 0, + epoch: clock.epoch + 100, + custodian: Pubkey::new_unique(), + }; + + assert!(lockup.is_in_force(&clock, None)); + lockup + } else { + Lockup::default() + }; + + // we put an extra minimum in every account, unstaked, to test that no new lamports activate + // name them here so our asserts are readable + let source_excess = minimum_delegation; + let dest_excess = minimum_delegation; + + let move_source_keypair = Keypair::new(); + let move_dest_keypair = Keypair::new(); + let staker_keypair = Keypair::new(); + let withdrawer_keypair = Keypair::new(); + + // create source stake + move_source_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + &move_source_keypair, + &staker_keypair, + &withdrawer_keypair, + &lockup, + ) + .await; + let move_source = move_source_keypair.pubkey(); + let mut source_account = get_account(&mut context.banks_client, &move_source).await; + let mut source_stake_state: StakeStateV2 = bincode::deserialize(&source_account.data).unwrap(); + + // create dest stake with same authorities + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &lockup, + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + // true up source epoch if transient + if move_source_type == StakeLifecycle::Activating + || move_source_type == StakeLifecycle::Deactivating + { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + if let StakeStateV2::Stake(_, ref mut stake, _) = &mut source_stake_state { + match move_source_type { + StakeLifecycle::Activating => stake.delegation.activation_epoch = clock.epoch, + StakeLifecycle::Deactivating => stake.delegation.deactivation_epoch = clock.epoch, + _ => (), + } + } + + source_account.data = bincode::serialize(&source_stake_state).unwrap(); + context.set_account(&move_source, &source_account.into()); + } + + // our inactive accounts have extra lamports, lets not let active feel left out + if move_dest_type == StakeLifecycle::Active { + transfer(&mut context, &move_dest, dest_excess).await; + } + + // hey why not spread the love around to everyone + transfer(&mut context, &move_source, source_excess).await; + + // alright first things first, clear out all the state failures + match (move_source_type, move_dest_type) { + // valid + (StakeLifecycle::Active, StakeLifecycle::Initialized) + | (StakeLifecycle::Active, StakeLifecycle::Active) + | (StakeLifecycle::Active, StakeLifecycle::Deactive) => (), + // invalid! get outta my test + _ => { + let instruction = ixn::move_stake( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + if full_move { + source_staked_amount + } else { + minimum_delegation + }, + ); + + // this is InvalidAccountData sometimes and Custom(5) sometimes but i dont care + process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + return; + } + } + + // source has 2x minimum (always 2 sol because these tests dont have featuresets) + // so first for inactive accounts lets undershoot and fail for underfunded dest + if move_dest_type != StakeLifecycle::Active { + let instruction = ixn::move_stake( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation - 1, + ); + + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + } + + // now lets overshoot and fail for underfunded source + let instruction = ixn::move_stake( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation + 1, + ); + + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + + // now we do it juuust right + let instruction = ixn::move_stake( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + if full_move { + source_staked_amount + } else { + minimum_delegation + }, + ); + + test_instruction_with_missing_signers(&mut context, &instruction, &vec![&staker_keypair]).await; + + if full_move { + let (_, option_source_stake, source_lamports) = + get_stake_account(&mut context.banks_client, &move_source).await; + + // source is deactivated and rent/excess stay behind + assert!(option_source_stake.is_none()); + assert_eq!(source_lamports, source_excess + rent_exempt_reserve); + + let (_, Some(dest_stake), dest_lamports) = + get_stake_account(&mut context.banks_client, &move_dest).await + else { + panic!("dest should be active") + }; + let dest_effective_stake = get_effective_stake(&mut context.banks_client, &move_dest).await; + + // dest captured the entire source delegation, kept its rent/excess, didnt activate its excess + assert_eq!( + dest_stake.delegation.stake, + source_staked_amount + dest_staked_amount + ); + assert_eq!(dest_effective_stake, dest_stake.delegation.stake); + assert_eq!( + dest_lamports, + dest_effective_stake + dest_excess + rent_exempt_reserve + ); + } else { + let (_, Some(source_stake), source_lamports) = + get_stake_account(&mut context.banks_client, &move_source).await + else { + panic!("source should be active") + }; + let source_effective_stake = + get_effective_stake(&mut context.banks_client, &move_source).await; + + // half of source delegation moved over, excess stayed behind + assert_eq!(source_stake.delegation.stake, source_staked_amount / 2); + assert_eq!(source_effective_stake, source_stake.delegation.stake); + assert_eq!( + source_lamports, + source_effective_stake + source_excess + rent_exempt_reserve + ); + + let (_, Some(dest_stake), dest_lamports) = + get_stake_account(&mut context.banks_client, &move_dest).await + else { + panic!("dest should be active") + }; + let dest_effective_stake = get_effective_stake(&mut context.banks_client, &move_dest).await; + + // dest mirrors our observations + assert_eq!( + dest_stake.delegation.stake, + source_staked_amount / 2 + dest_staked_amount + ); + assert_eq!(dest_effective_stake, dest_stake.delegation.stake); + assert_eq!( + dest_lamports, + dest_effective_stake + dest_excess + rent_exempt_reserve + ); + } +} + +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, + StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, + StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [false, true], + [false, true] +)] +#[tokio::test] +async fn test_move_lamports( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + different_votes: bool, + has_lockup: bool, +) { + let mut context = program_test().start_with_context().await; + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let rent_exempt_reserve = get_stake_account_rent(&mut context.banks_client).await; + let minimum_delegation = get_minimum_delegation(&mut context).await; + + // put minimum in both accounts if theyre active + let source_staked_amount = if move_source_type == StakeLifecycle::Active { + minimum_delegation + } else { + 0 + }; + + let dest_staked_amount = if move_dest_type == StakeLifecycle::Active { + minimum_delegation + } else { + 0 + }; + + // test with and without lockup. both of these cases pass, we test failures elsewhere + let lockup = if has_lockup { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + let lockup = Lockup { + unix_timestamp: 0, + epoch: clock.epoch + 100, + custodian: Pubkey::new_unique(), + }; + + assert!(lockup.is_in_force(&clock, None)); + lockup + } else { + Lockup::default() + }; + + // we put an extra minimum in every account, unstaked, to test moving them + let source_excess = minimum_delegation; + let dest_excess = minimum_delegation; + + let move_source_keypair = Keypair::new(); + let move_dest_keypair = Keypair::new(); + let staker_keypair = Keypair::new(); + let withdrawer_keypair = Keypair::new(); + + // make a separate vote account if needed + let dest_vote_account = if different_votes { + let vote_account = Keypair::new(); + create_vote( + &mut context, + &Keypair::new(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &vote_account, + ) + .await; + + vote_account.pubkey() + } else { + accounts.vote_account.pubkey() + }; + + // create source stake + move_source_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_source_keypair, + &staker_keypair, + &withdrawer_keypair, + &lockup, + ) + .await; + let move_source = move_source_keypair.pubkey(); + let mut source_account = get_account(&mut context.banks_client, &move_source).await; + let mut source_stake_state: StakeStateV2 = bincode::deserialize(&source_account.data).unwrap(); + + // create dest stake with same authorities + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &dest_vote_account, + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &lockup, + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + // true up source epoch if transient + if move_source_type == StakeLifecycle::Activating + || move_source_type == StakeLifecycle::Deactivating + { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + if let StakeStateV2::Stake(_, ref mut stake, _) = &mut source_stake_state { + match move_source_type { + StakeLifecycle::Activating => stake.delegation.activation_epoch = clock.epoch, + StakeLifecycle::Deactivating => stake.delegation.deactivation_epoch = clock.epoch, + _ => (), + } + } + + source_account.data = bincode::serialize(&source_stake_state).unwrap(); + context.set_account(&move_source, &source_account.into()); + } + + // if we activated the initial amount we need to top up with the test lamports + if move_source_type == StakeLifecycle::Active { + transfer(&mut context, &move_source, source_excess).await; + } + if move_dest_type == StakeLifecycle::Active { + transfer(&mut context, &move_dest, dest_excess).await; + } + + // clear out state failures + if move_source_type == StakeLifecycle::Activating + || move_source_type == StakeLifecycle::Deactivating + || move_dest_type == StakeLifecycle::Deactivating + { + let instruction = ixn::move_lamports( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + source_excess, + ); + + process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + return; + } + + // overshoot and fail for underfunded source + let instruction = ixn::move_lamports( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + source_excess + 1, + ); + + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + + let (_, _, before_source_lamports) = + get_stake_account(&mut context.banks_client, &move_source).await; + let (_, _, before_dest_lamports) = + get_stake_account(&mut context.banks_client, &move_dest).await; + + // now properly move the full excess + let instruction = ixn::move_lamports( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + source_excess, + ); + + test_instruction_with_missing_signers(&mut context, &instruction, &vec![&staker_keypair]).await; + + let (_, _, after_source_lamports) = + get_stake_account(&mut context.banks_client, &move_source).await; + let source_effective_stake = get_effective_stake(&mut context.banks_client, &move_source).await; + + // source activation didnt change + assert_eq!(source_effective_stake, source_staked_amount); + + // source lamports are right + assert_eq!( + after_source_lamports, + before_source_lamports - minimum_delegation + ); + assert_eq!( + after_source_lamports, + source_effective_stake + rent_exempt_reserve + ); + + let (_, _, after_dest_lamports) = + get_stake_account(&mut context.banks_client, &move_dest).await; + let dest_effective_stake = get_effective_stake(&mut context.banks_client, &move_dest).await; + + // dest activation didnt change + assert_eq!(dest_effective_stake, dest_staked_amount); + + // dest lamports are right + assert_eq!( + after_dest_lamports, + before_dest_lamports + minimum_delegation + ); + assert_eq!( + after_dest_lamports, + dest_effective_stake + rent_exempt_reserve + source_excess + dest_excess + ); +} + +#[test_matrix( + [(StakeLifecycle::Active, StakeLifecycle::Uninitialized), + (StakeLifecycle::Uninitialized, StakeLifecycle::Initialized), + (StakeLifecycle::Uninitialized, StakeLifecycle::Uninitialized)], + [false, true] +)] +#[tokio::test] +async fn test_move_uninitialized_fail( + move_types: (StakeLifecycle, StakeLifecycle), + move_lamports: bool, +) { + let mut context = program_test().start_with_context().await; + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let minimum_delegation = get_minimum_delegation(&mut context).await; + let source_staked_amount = minimum_delegation * 2; + + let (move_source_type, move_dest_type) = move_types; + + let (move_source_keypair, staker_keypair, withdrawer_keypair) = move_source_type + .new_stake_account( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + ) + .await; + let move_source = move_source_keypair.pubkey(); + + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + 0, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let source_signer = if move_source_type == StakeLifecycle::Uninitialized { + &move_source_keypair + } else { + &staker_keypair + }; + + let instruction = if move_lamports { + ixn::move_lamports( + &move_source, + &move_dest, + &source_signer.pubkey(), + minimum_delegation, + ) + } else { + ixn::move_stake( + &move_source, + &move_dest, + &source_signer.pubkey(), + minimum_delegation, + ) + }; + + let e = process_instruction(&mut context, &instruction, &vec![source_signer]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidAccountData); +} + +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Active, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, StakeLifecycle::Deactive], + [false, true] +)] +#[tokio::test] +async fn test_move_general_fail( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + move_lamports: bool, +) { + // clear the states that are only valid for move_lamports + if !move_lamports + && (move_source_type != StakeLifecycle::Active + || move_dest_type == StakeLifecycle::Activating) + { + return; + } + + let mut context = program_test().start_with_context().await; + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let minimum_delegation = get_minimum_delegation(&mut context).await; + let source_staked_amount = minimum_delegation * 2; + + let in_force_lockup = { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + Lockup { + unix_timestamp: 0, + epoch: clock.epoch + 1_000_000, + custodian: Pubkey::new_unique(), + } + }; + + let mk_ixn = if move_lamports { + ixn::move_lamports + } else { + ixn::move_stake + }; + + // we can reuse source but will need a lot of dest + let (move_source_keypair, staker_keypair, withdrawer_keypair) = move_source_type + .new_stake_account( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + ) + .await; + let move_source = move_source_keypair.pubkey(); + transfer(&mut context, &move_source, minimum_delegation).await; + + // self-move fails + // NOTE this error type is an artifact of the native program interface + // when we move to bpf, it should actually hit the processor error + let instruction = mk_ixn( + &move_source, + &move_source, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::AccountBorrowFailed); + + // first we make a "normal" move dest + { + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + // zero move fails + let instruction = mk_ixn(&move_source, &move_dest, &staker_keypair.pubkey(), 0); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + + // sign with withdrawer fails + let instruction = mk_ixn( + &move_source, + &move_dest, + &withdrawer_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&withdrawer_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::MissingRequiredSignature); + + // good place to test source lockup + let move_locked_source_keypair = Keypair::new(); + move_source_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + &move_locked_source_keypair, + &staker_keypair, + &withdrawer_keypair, + &in_force_lockup, + ) + .await; + let move_locked_source = move_locked_source_keypair.pubkey(); + transfer(&mut context, &move_locked_source, minimum_delegation).await; + + let instruction = mk_ixn( + &move_locked_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::MergeMismatch.into()); + } + + // staker mismatch + { + let move_dest_keypair = Keypair::new(); + let throwaway = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &throwaway, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::MergeMismatch.into()); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &throwaway.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&throwaway]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::MissingRequiredSignature); + } + + // withdrawer mismatch + { + let move_dest_keypair = Keypair::new(); + let throwaway = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &throwaway, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::MergeMismatch.into()); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &throwaway.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&throwaway]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::MissingRequiredSignature); + } + + // dest lockup + { + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &in_force_lockup, + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::MergeMismatch.into()); + } + + // lastly we test different vote accounts for move_stake + if !move_lamports && move_dest_type == StakeLifecycle::Active { + let dest_vote_account_keypair = Keypair::new(); + create_vote( + &mut context, + &Keypair::new(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &dest_vote_account_keypair, + ) + .await; + + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &dest_vote_account_keypair.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::VoteAddressMismatch.into()); + } +} + +// this test is only to be sure the feature gate is safe +// once the feature has been activated, this can all be deleted +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Active, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, StakeLifecycle::Deactive], + [false, true] +)] +#[tokio::test] +async fn test_move_feature_gate_fail( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + move_lamports: bool, +) { + if !move_lamports + && (move_source_type != StakeLifecycle::Active + || move_dest_type == StakeLifecycle::Activating) + { + return; + } + + let mut context = program_test_without_features(&[move_stake_and_move_lamports_ixs::id()]) + .start_with_context() + .await; + + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let minimum_delegation = get_minimum_delegation(&mut context).await; + let source_staked_amount = minimum_delegation * 2; + + let mk_ixn = if move_lamports { + ixn::move_lamports + } else { + ixn::move_stake + }; + + let (move_source_keypair, staker_keypair, withdrawer_keypair) = move_source_type + .new_stake_account( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + ) + .await; + let move_source = move_source_keypair.pubkey(); + transfer(&mut context, &move_source, minimum_delegation).await; + + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidInstructionData); +} From 87bd2917b8cead9a3016ae429b92bc569c5d7b67 Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:04:44 -0700 Subject: [PATCH 06/11] address initial feedback --- .../tests/test_move_stake_and_lamports.rs | 13 +- programs/stake/src/stake_instruction.rs | 2 - programs/stake/src/stake_state.rs | 154 +++++++++--------- 3 files changed, 88 insertions(+), 81 deletions(-) diff --git a/programs/stake-tests/tests/test_move_stake_and_lamports.rs b/programs/stake-tests/tests/test_move_stake_and_lamports.rs index 7c3a66f4e46f2d..7c67db2d5520b9 100644 --- a/programs/stake-tests/tests/test_move_stake_and_lamports.rs +++ b/programs/stake-tests/tests/test_move_stake_and_lamports.rs @@ -74,13 +74,11 @@ impl Accounts { impl Default for Accounts { fn default() -> Self { - let vote_account = Keypair::new(); - Self { validator: Keypair::new(), voter: Keypair::new(), withdrawer: Keypair::new(), - vote_account, + vote_account: Keypair::new(), } } } @@ -943,7 +941,10 @@ async fn test_move_general_fail( move_dest_type: StakeLifecycle, move_lamports: bool, ) { - // clear the states that are only valid for move_lamports + // the test_matrix includes all valid source/dest combinations for MoveLamports + // we dont test invalid combinations because they would fail regardless of the fail cases we test here + // valid source/dest for MoveStake are a strict subset of MoveLamports + // source must be active, and dest must be active or inactive. so we skip the additional invalid MoveStake cases if !move_lamports && (move_source_type != StakeLifecycle::Active || move_dest_type == StakeLifecycle::Activating) @@ -1221,6 +1222,10 @@ async fn test_move_feature_gate_fail( move_dest_type: StakeLifecycle, move_lamports: bool, ) { + // the test_matrix includes all valid source/dest combinations for MoveLamports + // we dont test invalid combinations because they would fail regardless of the fail cases we test here + // valid source/dest for MoveStake are a strict subset of MoveLamports + // source must be active, and dest must be active or inactive. so we skip the additional invalid MoveStake cases if !move_lamports && (move_source_type != StakeLifecycle::Active || move_dest_type == StakeLifecycle::Activating) diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index e6cf2a2320c22e..1ca36e6fb13d18 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -359,7 +359,6 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| .get_feature_set() .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) { - instruction_context.check_number_of_instruction_accounts(2)?; let clock = get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; let stake_history = get_sysvar_with_account_check::stake_history( @@ -390,7 +389,6 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| .get_feature_set() .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) { - instruction_context.check_number_of_instruction_accounts(2)?; let clock = get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; let stake_history = get_sysvar_with_account_check::stake_history( diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index cf7e0047c09b26..556e80dbd728c6 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -824,95 +824,99 @@ pub fn move_stake( // destination must not be activating // if active, destination must be delegated to the same vote account as source // minimum delegations must be respected for any accounts that become/remain active - match source_merge_kind { - MergeKind::FullyActive(source_meta, mut source_stake) => { - let minimum_delegation = - crate::get_minimum_delegation(invoke_context.get_feature_set()); - - let source_effective_stake = source_stake.delegation.stake; - let source_final_stake = source_effective_stake - .checked_sub(lamports) - .ok_or(InstructionError::InvalidArgument)?; - - if source_final_stake != 0 && source_final_stake < minimum_delegation { - return Err(InstructionError::InvalidArgument); - } + let MergeKind::FullyActive(source_meta, mut source_stake) = source_merge_kind else { + return Err(InstructionError::InvalidAccountData); + }; - let destination_meta = match destination_merge_kind { - MergeKind::FullyActive(destination_meta, mut destination_stake) => { - if source_stake.delegation.voter_pubkey - != destination_stake.delegation.voter_pubkey - { - return Err(StakeError::VoteAddressMismatch.into()); - } + let minimum_delegation = crate::get_minimum_delegation(invoke_context.get_feature_set()); - let destination_effective_stake = destination_stake.delegation.stake; - let destination_final_stake = destination_effective_stake - .checked_add(lamports) - .ok_or(InstructionError::ArithmeticOverflow)?; + let source_effective_stake = source_stake.delegation.stake; + let source_final_stake = source_effective_stake + .checked_sub(lamports) + .ok_or(InstructionError::InvalidArgument)?; - if destination_final_stake < minimum_delegation { - return Err(InstructionError::InvalidArgument); - } + if source_final_stake != 0 && source_final_stake < minimum_delegation { + return Err(InstructionError::InvalidArgument); + } - merge_delegation_stake_and_credits_observed( - &mut destination_stake, - lamports, - source_stake.credits_observed, - )?; + let destination_meta = match destination_merge_kind { + MergeKind::FullyActive(destination_meta, mut destination_stake) => { + if source_stake.delegation.voter_pubkey != destination_stake.delegation.voter_pubkey { + return Err(StakeError::VoteAddressMismatch.into()); + } - destination_account.set_state(&StakeStateV2::Stake( - destination_meta, - destination_stake, - StakeFlags::empty(), - ))?; + let destination_effective_stake = destination_stake.delegation.stake; + let destination_final_stake = destination_effective_stake + .checked_add(lamports) + .ok_or(InstructionError::ArithmeticOverflow)?; - destination_meta - } - MergeKind::Inactive(destination_meta, _, _) => { - if lamports < minimum_delegation { - return Err(InstructionError::InvalidArgument); - } + if destination_final_stake < minimum_delegation { + return Err(InstructionError::InvalidArgument); + } - let mut destination_stake = source_stake; - destination_stake.delegation.stake = lamports; - destination_account.set_state(&StakeStateV2::Stake( - destination_meta, - destination_stake, - StakeFlags::empty(), - ))?; + merge_delegation_stake_and_credits_observed( + &mut destination_stake, + lamports, + source_stake.credits_observed, + )?; - destination_meta - } - _ => return Err(InstructionError::InvalidAccountData), - }; + // StakeFlags::empty() is valid here because the only existing stake flag, + // MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, does not apply to active stakes + destination_account.set_state(&StakeStateV2::Stake( + destination_meta, + destination_stake, + StakeFlags::empty(), + ))?; - if source_final_stake == 0 { - source_account.set_state(&StakeStateV2::Initialized(source_meta))?; - } else { - source_stake.delegation.stake = source_final_stake; - source_account.set_state(&StakeStateV2::Stake( - source_meta, - source_stake, - StakeFlags::empty(), - ))?; + destination_meta + } + MergeKind::Inactive(destination_meta, _, _) => { + if lamports < minimum_delegation { + return Err(InstructionError::InvalidArgument); } - source_account.checked_sub_lamports(lamports)?; - destination_account.checked_add_lamports(lamports)?; + let mut destination_stake = source_stake; + destination_stake.delegation.stake = lamports; - // this should be impossible, but because we do all our math with delegations, best to guard it - if source_account.get_lamports() < source_meta.rent_exempt_reserve - || destination_account.get_lamports() < destination_meta.rent_exempt_reserve - { - ic_msg!( - invoke_context, - "Delegation calculations violated lamport balance assumptions" - ); - return Err(InstructionError::InvalidArgument); - } + // StakeFlags::empty() is valid here because the only existing stake flag, + // MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, is cleared when a stake is activated + destination_account.set_state(&StakeStateV2::Stake( + destination_meta, + destination_stake, + StakeFlags::empty(), + ))?; + + destination_meta } _ => return Err(InstructionError::InvalidAccountData), + }; + + if source_final_stake == 0 { + source_account.set_state(&StakeStateV2::Initialized(source_meta))?; + } else { + source_stake.delegation.stake = source_final_stake; + + // StakeFlags::empty() is valid here because the only existing stake flag, + // MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, does not apply to active stakes + source_account.set_state(&StakeStateV2::Stake( + source_meta, + source_stake, + StakeFlags::empty(), + ))?; + } + + source_account.checked_sub_lamports(lamports)?; + destination_account.checked_add_lamports(lamports)?; + + // this should be impossible, but because we do all our math with delegations, best to guard it + if source_account.get_lamports() < source_meta.rent_exempt_reserve + || destination_account.get_lamports() < destination_meta.rent_exempt_reserve + { + ic_msg!( + invoke_context, + "Delegation calculations violated lamport balance assumptions" + ); + return Err(InstructionError::InvalidArgument); } Ok(()) From 59eaa8a4ce922398e018fddd4a71628cb98c918a Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:19:05 -0700 Subject: [PATCH 07/11] test faked sysvars --- .../tests/test_move_stake_and_lamports.rs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/programs/stake-tests/tests/test_move_stake_and_lamports.rs b/programs/stake-tests/tests/test_move_stake_and_lamports.rs index 7c67db2d5520b9..80893825b7cbc8 100644 --- a/programs/stake-tests/tests/test_move_stake_and_lamports.rs +++ b/programs/stake-tests/tests/test_move_stake_and_lamports.rs @@ -23,7 +23,7 @@ use { state::{Authorized, Lockup, Meta, Stake, StakeStateV2}, }, system_instruction, system_program, - sysvar::{clock::Clock, stake_history::StakeHistory}, + sysvar::{clock::Clock, stake_history::StakeHistory, SysvarId}, transaction::{Transaction, TransactionError}, }, solana_vote_program::{ @@ -952,7 +952,14 @@ async fn test_move_general_fail( return; } - let mut context = program_test().start_with_context().await; + let fake_clock = Pubkey::new_unique(); + let fake_stake_history = Pubkey::new_unique(); + + let mut program_test = program_test(); + program_test.add_sysvar_account(fake_clock, &Clock::default()); + program_test.add_sysvar_account(fake_stake_history, &StakeHistory::default()); + + let mut context = program_test.start_with_context().await; let accounts = Accounts::default(); accounts.initialize(&mut context).await; @@ -1034,6 +1041,34 @@ async fn test_move_general_fail( .unwrap_err(); assert_eq!(e, ProgramError::MissingRequiredSignature); + // spoofed clock fails + let mut instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + assert_eq!(instruction.accounts[2].pubkey, Clock::id()); + instruction.accounts[2].pubkey = fake_clock; + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + + // spoofed stake history fails + let mut instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + assert_eq!(instruction.accounts[3].pubkey, StakeHistory::id()); + instruction.accounts[3].pubkey = fake_stake_history; + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + // good place to test source lockup let move_locked_source_keypair = Keypair::new(); move_source_type From 7523014e6731670a0e0575b1f220f29e7b67fb2b Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Thu, 13 Jun 2024 15:39:51 -0700 Subject: [PATCH 08/11] get sysvars from invoke context instead of account data (simd already updated for this) --- .../tests/test_move_stake_and_lamports.rs | 39 +--------------- programs/stake/src/stake_instruction.rs | 27 ++--------- programs/stake/src/stake_state.rs | 23 ++++------ sdk/program/src/stake/instruction.rs | 46 ++++++------------- 4 files changed, 29 insertions(+), 106 deletions(-) diff --git a/programs/stake-tests/tests/test_move_stake_and_lamports.rs b/programs/stake-tests/tests/test_move_stake_and_lamports.rs index 80893825b7cbc8..7c67db2d5520b9 100644 --- a/programs/stake-tests/tests/test_move_stake_and_lamports.rs +++ b/programs/stake-tests/tests/test_move_stake_and_lamports.rs @@ -23,7 +23,7 @@ use { state::{Authorized, Lockup, Meta, Stake, StakeStateV2}, }, system_instruction, system_program, - sysvar::{clock::Clock, stake_history::StakeHistory, SysvarId}, + sysvar::{clock::Clock, stake_history::StakeHistory}, transaction::{Transaction, TransactionError}, }, solana_vote_program::{ @@ -952,14 +952,7 @@ async fn test_move_general_fail( return; } - let fake_clock = Pubkey::new_unique(); - let fake_stake_history = Pubkey::new_unique(); - - let mut program_test = program_test(); - program_test.add_sysvar_account(fake_clock, &Clock::default()); - program_test.add_sysvar_account(fake_stake_history, &StakeHistory::default()); - - let mut context = program_test.start_with_context().await; + let mut context = program_test().start_with_context().await; let accounts = Accounts::default(); accounts.initialize(&mut context).await; @@ -1041,34 +1034,6 @@ async fn test_move_general_fail( .unwrap_err(); assert_eq!(e, ProgramError::MissingRequiredSignature); - // spoofed clock fails - let mut instruction = mk_ixn( - &move_source, - &move_dest, - &staker_keypair.pubkey(), - minimum_delegation, - ); - assert_eq!(instruction.accounts[2].pubkey, Clock::id()); - instruction.accounts[2].pubkey = fake_clock; - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::InvalidArgument); - - // spoofed stake history fails - let mut instruction = mk_ixn( - &move_source, - &move_dest, - &staker_keypair.pubkey(), - minimum_delegation, - ); - assert_eq!(instruction.accounts[3].pubkey, StakeHistory::id()); - instruction.accounts[3].pubkey = fake_stake_history; - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::InvalidArgument); - // good place to test source lockup let move_locked_source_keypair = Keypair::new(); move_source_type diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index 1ca36e6fb13d18..567e03565b7bd6 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -359,14 +359,7 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| .get_feature_set() .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) { - let clock = - get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; - let stake_history = get_sysvar_with_account_check::stake_history( - invoke_context, - instruction_context, - 3, - )?; - instruction_context.check_number_of_instruction_accounts(5)?; + instruction_context.check_number_of_instruction_accounts(3)?; drop(me); move_stake( invoke_context, @@ -375,9 +368,7 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| 0, lamports, 1, - &clock, - &stake_history, - 4, + 2, ) } else { Err(InstructionError::InvalidInstructionData) @@ -389,14 +380,7 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| .get_feature_set() .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) { - let clock = - get_sysvar_with_account_check::clock(invoke_context, instruction_context, 2)?; - let stake_history = get_sysvar_with_account_check::stake_history( - invoke_context, - instruction_context, - 3, - )?; - instruction_context.check_number_of_instruction_accounts(5)?; + instruction_context.check_number_of_instruction_accounts(3)?; drop(me); move_lamports( invoke_context, @@ -405,9 +389,7 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| 0, lamports, 1, - &clock, - &stake_history, - 4, + 2, ) } else { Err(InstructionError::InvalidInstructionData) @@ -537,6 +519,7 @@ mod tests { .collect(); pubkeys.insert(clock::id()); pubkeys.insert(epoch_schedule::id()); + pubkeys.insert(stake_history::id()); #[allow(deprecated)] pubkeys .iter() diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index 556e80dbd728c6..97ea34dc2f2c45 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -139,8 +139,6 @@ fn move_stake_or_lamports_shared_checks( source_account: &BorrowedAccount, lamports: u64, destination_account: &BorrowedAccount, - clock: &Clock, - stake_history: &StakeHistory, stake_authority_index: IndexOfAccount, ) -> Result<(MergeKind, MergeKind), InstructionError> { // authority must sign @@ -175,14 +173,17 @@ fn move_stake_or_lamports_shared_checks( return Err(InstructionError::InvalidArgument); } + let clock = invoke_context.get_sysvar_cache().get_clock()?; + let stake_history = invoke_context.get_sysvar_cache().get_stake_history()?; + // get_if_mergeable ensures accounts are not partly activated or in any form of deactivating // we still need to exclude activating state ourselves let source_merge_kind = MergeKind::get_if_mergeable( invoke_context, &source_account.get_state()?, source_account.get_lamports(), - clock, - stake_history, + &clock, + &stake_history, )?; // Authorized staker is allowed to move stake @@ -196,8 +197,8 @@ fn move_stake_or_lamports_shared_checks( invoke_context, &destination_account.get_state()?, destination_account.get_lamports(), - clock, - stake_history, + &clock, + &stake_history, )?; // ensure all authorities match and lockups match if lockup is in force @@ -205,7 +206,7 @@ fn move_stake_or_lamports_shared_checks( invoke_context, source_merge_kind.meta(), destination_merge_kind.meta(), - clock, + &clock, )?; Ok((source_merge_kind, destination_merge_kind)) @@ -791,8 +792,6 @@ pub fn move_stake( source_account_index: IndexOfAccount, lamports: u64, destination_account_index: IndexOfAccount, - clock: &Clock, - stake_history: &StakeHistory, stake_authority_index: IndexOfAccount, ) -> Result<(), InstructionError> { let mut source_account = instruction_context @@ -808,8 +807,6 @@ pub fn move_stake( &source_account, lamports, &destination_account, - clock, - stake_history, stake_authority_index, )?; @@ -929,8 +926,6 @@ pub fn move_lamports( source_account_index: IndexOfAccount, lamports: u64, destination_account_index: IndexOfAccount, - clock: &Clock, - stake_history: &StakeHistory, stake_authority_index: IndexOfAccount, ) -> Result<(), InstructionError> { let mut source_account = instruction_context @@ -946,8 +941,6 @@ pub fn move_lamports( &source_account, lamports, &destination_account, - clock, - stake_history, stake_authority_index, )?; diff --git a/sdk/program/src/stake/instruction.rs b/sdk/program/src/stake/instruction.rs index cfa063ba80f58b..4fa50e4f3d764d 100644 --- a/sdk/program/src/stake/instruction.rs +++ b/sdk/program/src/stake/instruction.rs @@ -324,9 +324,7 @@ pub enum StakeInstruction { /// # Account references /// 0. `[WRITE]` Active source stake account /// 1. `[WRITE]` Active or inactive destination stake account - /// 2. `[]` Clock sysvar - /// 3. `[]` Stake history sysvar that carries stake warmup/cooldown history - /// 4. `[SIGNER]` Stake authority + /// 2. `[SIGNER]` Stake authority /// /// The u64 is the portion of the stake to move, which may be the entire delegation MoveStake(u64), @@ -341,9 +339,7 @@ pub enum StakeInstruction { /// # Account references /// 0. `[WRITE]` Active or inactive source stake account /// 1. `[WRITE]` Mergeable destination stake account - /// 2. `[]` Clock sysvar - /// 3. `[]` Stake history sysvar that carries stake warmup/cooldown history - /// 4. `[SIGNER]` Stake authority + /// 2. `[SIGNER]` Stake authority /// /// The u64 is the portion of available lamports to move MoveLamports(u64), @@ -893,46 +889,32 @@ pub fn move_stake( authorized_pubkey: &Pubkey, lamports: u64, ) -> Instruction { - move_stake_or_lamports( - source_stake_pubkey, - destination_stake_pubkey, - authorized_pubkey, - lamports, - &(StakeInstruction::MoveStake as fn(u64) -> StakeInstruction), - ) -} + let account_metas = vec![ + AccountMeta::new(*source_stake_pubkey, false), + AccountMeta::new(*destination_stake_pubkey, false), + AccountMeta::new_readonly(*authorized_pubkey, true), + ]; -pub fn move_lamports( - source_stake_pubkey: &Pubkey, - destination_stake_pubkey: &Pubkey, - authorized_pubkey: &Pubkey, - lamports: u64, -) -> Instruction { - move_stake_or_lamports( - source_stake_pubkey, - destination_stake_pubkey, - authorized_pubkey, - lamports, - &(StakeInstruction::MoveLamports as fn(u64) -> StakeInstruction), - ) + Instruction::new_with_bincode(id(), &StakeInstruction::MoveStake(lamports), account_metas) } -fn move_stake_or_lamports( +pub fn move_lamports( source_stake_pubkey: &Pubkey, destination_stake_pubkey: &Pubkey, authorized_pubkey: &Pubkey, lamports: u64, - value_constructor: &fn(u64) -> StakeInstruction, ) -> Instruction { let account_metas = vec![ AccountMeta::new(*source_stake_pubkey, false), AccountMeta::new(*destination_stake_pubkey, false), - AccountMeta::new_readonly(sysvar::clock::id(), false), - AccountMeta::new_readonly(sysvar::stake_history::id(), false), AccountMeta::new_readonly(*authorized_pubkey, true), ]; - Instruction::new_with_bincode(id(), &value_constructor(lamports), account_metas) + Instruction::new_with_bincode( + id(), + &StakeInstruction::MoveLamports(lamports), + account_metas, + ) } #[cfg(test)] From 23cd9451c38b5e72cc185e9f210a3e1adba23ceb Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Thu, 13 Jun 2024 16:03:04 -0700 Subject: [PATCH 09/11] remaining minor cleanups --- programs/stake/src/stake_instruction.rs | 24 ------------------------ programs/stake/src/stake_state.rs | 15 ++++++++++----- sdk/program/src/stake/instruction.rs | 2 +- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index 567e03565b7bd6..1b874cd9750596 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -354,13 +354,11 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| } } StakeInstruction::MoveStake(lamports) => { - let me = get_stake_account()?; if invoke_context .get_feature_set() .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) { instruction_context.check_number_of_instruction_accounts(3)?; - drop(me); move_stake( invoke_context, transaction_context, @@ -375,13 +373,11 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| } } StakeInstruction::MoveLamports(lamports) => { - let me = get_stake_account()?; if invoke_context .get_feature_set() .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) { instruction_context.check_number_of_instruction_accounts(3)?; - drop(me); move_lamports( invoke_context, transaction_context, @@ -863,26 +859,6 @@ mod tests { )[2], Err(InstructionError::InvalidAccountOwner), ); - process_instruction_as_one_arg( - Arc::clone(&feature_set), - &instruction::move_stake( - &spoofed_stake_state_pubkey(), - &Pubkey::new_unique(), - &Pubkey::new_unique(), - 100, - ), - Err(InstructionError::InvalidAccountOwner), - ); - process_instruction_as_one_arg( - Arc::clone(&feature_set), - &instruction::move_lamports( - &spoofed_stake_state_pubkey(), - &Pubkey::new_unique(), - &Pubkey::new_unique(), - 100, - ), - Err(InstructionError::InvalidAccountOwner), - ); } #[test_case(feature_set_no_minimum_delegation(); "no_min_delegation")] diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index 97ea34dc2f2c45..baee081034703d 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -810,7 +810,8 @@ pub fn move_stake( stake_authority_index, )?; - // source and destination will have their data reassigned + // ensure source and destination are the right size for the current version of StakeState + // this a safeguard in case there is a new version of the struct that cannot fit into an old account if source_account.get_data().len() != StakeStateV2::size_of() || destination_account.get_data().len() != StakeStateV2::size_of() { @@ -818,26 +819,27 @@ pub fn move_stake( } // source must be fully active - // destination must not be activating - // if active, destination must be delegated to the same vote account as source - // minimum delegations must be respected for any accounts that become/remain active let MergeKind::FullyActive(source_meta, mut source_stake) = source_merge_kind else { return Err(InstructionError::InvalidAccountData); }; let minimum_delegation = crate::get_minimum_delegation(invoke_context.get_feature_set()); - let source_effective_stake = source_stake.delegation.stake; + + // source cannot move more stake than it has, regardless of how many lamports it has let source_final_stake = source_effective_stake .checked_sub(lamports) .ok_or(InstructionError::InvalidArgument)?; + // unless all stake is being moved, source must retain at least the minimum delegation if source_final_stake != 0 && source_final_stake < minimum_delegation { return Err(InstructionError::InvalidArgument); } + // destination must be fully active or fully inactive let destination_meta = match destination_merge_kind { MergeKind::FullyActive(destination_meta, mut destination_stake) => { + // if active, destination must be delegated to the same vote account as source if source_stake.delegation.voter_pubkey != destination_stake.delegation.voter_pubkey { return Err(StakeError::VoteAddressMismatch.into()); } @@ -847,6 +849,8 @@ pub fn move_stake( .checked_add(lamports) .ok_or(InstructionError::ArithmeticOverflow)?; + // ensure destination meets miniumum delegation + // since it is already active, this only really applies if the minimum is raised if destination_final_stake < minimum_delegation { return Err(InstructionError::InvalidArgument); } @@ -868,6 +872,7 @@ pub fn move_stake( destination_meta } MergeKind::Inactive(destination_meta, _, _) => { + // if destination is inactive, it must be given at least the minimum delegation if lamports < minimum_delegation { return Err(InstructionError::InvalidArgument); } diff --git a/sdk/program/src/stake/instruction.rs b/sdk/program/src/stake/instruction.rs index 4fa50e4f3d764d..e2a5b056e70618 100644 --- a/sdk/program/src/stake/instruction.rs +++ b/sdk/program/src/stake/instruction.rs @@ -314,7 +314,7 @@ pub enum StakeInstruction { /// becomes inactive. Otherwise, at least the minimum delegation of active stake must remain. /// /// The destination account must be fully active or fully inactive. If it is active, it must - /// be delegated to the same vote accouunt as the source. If it is inactive, it + /// be delegated to the same vote account as the source. If it is inactive, it /// immediately becomes active, and must contain at least the minimum delegation. The /// destination must be pre-funded with the rent-exempt reserve. /// From 062459df76308d01ada1babd25eff9086cdcb699 Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Thu, 13 Jun 2024 18:26:28 -0700 Subject: [PATCH 10/11] fix txn parser --- transaction-status/src/parse_stake.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/transaction-status/src/parse_stake.rs b/transaction-status/src/parse_stake.rs index c39f1b2c638cad..f1586e58694950 100644 --- a/transaction-status/src/parse_stake.rs +++ b/transaction-status/src/parse_stake.rs @@ -298,29 +298,25 @@ pub fn parse_stake( }) } StakeInstruction::MoveStake(lamports) => { - check_num_stake_accounts(&instruction.accounts, 5)?; + check_num_stake_accounts(&instruction.accounts, 3)?; Ok(ParsedInstructionEnum { instruction_type: "moveStake".to_string(), info: json!({ "source": account_keys[instruction.accounts[0] as usize].to_string(), "destination": account_keys[instruction.accounts[1] as usize].to_string(), - "clockSysvar": account_keys[instruction.accounts[2] as usize].to_string(), - "stakeHistorySysvar": account_keys[instruction.accounts[3] as usize].to_string(), - "stakeAuthority": account_keys[instruction.accounts[4] as usize].to_string(), + "stakeAuthority": account_keys[instruction.accounts[2] as usize].to_string(), "lamports": lamports, }), }) } StakeInstruction::MoveLamports(lamports) => { - check_num_stake_accounts(&instruction.accounts, 5)?; + check_num_stake_accounts(&instruction.accounts, 3)?; Ok(ParsedInstructionEnum { instruction_type: "moveLamports".to_string(), info: json!({ "source": account_keys[instruction.accounts[0] as usize].to_string(), "destination": account_keys[instruction.accounts[1] as usize].to_string(), - "clockSysvar": account_keys[instruction.accounts[2] as usize].to_string(), - "stakeHistorySysvar": account_keys[instruction.accounts[3] as usize].to_string(), - "stakeAuthority": account_keys[instruction.accounts[4] as usize].to_string(), + "stakeAuthority": account_keys[instruction.accounts[2] as usize].to_string(), "lamports": lamports, }), }) @@ -1219,8 +1215,6 @@ mod test { info: json!({ "source": source_stake_pubkey.to_string(), "destination": destination_stake_pubkey.to_string(), - "clockSysvar": sysvar::clock::ID.to_string(), - "stakeHistorySysvar": sysvar::stake_history::ID.to_string(), "stakeAuthority": authorized_pubkey.to_string(), "lamports": lamports, }), @@ -1228,7 +1222,7 @@ mod test { ); assert!(parse_stake( &message.instructions[0], - &AccountKeys::new(&message.account_keys[0..4], None) + &AccountKeys::new(&message.account_keys[0..2], None) ) .is_err()); let keys = message.account_keys.clone(); From 94bbfa3ed0a5c5ed12f69ec3bbdcc4bc4fab3c93 Mon Sep 17 00:00:00 2001 From: hanako mumei <81144685+2501babe@users.noreply.github.com> Date: Thu, 27 Jun 2024 15:34:47 -0700 Subject: [PATCH 11/11] fix lockfile --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 2363eadc7e715a..93f2c32217cc1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7368,7 +7368,7 @@ dependencies = [ [[package]] name = "solana-stake-program-tests" -version = "2.0.0" +version = "2.1.0" dependencies = [ "assert_matches", "bincode",