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!(), } }