From 9c89b151496b631f701e279adaeddd723be76416 Mon Sep 17 00:00:00 2001 From: rustopian <96253492+rustopian@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:36:19 +0000 Subject: [PATCH 1/3] migrate move_lamports tests --- program/tests/helpers/instruction_builders.rs | 70 +++ program/tests/helpers/utils.rs | 39 ++ program/tests/move_lamports.rs | 438 ++++++++++++++ program/tests/program_test.rs | 563 ------------------ 4 files changed, 547 insertions(+), 563 deletions(-) create mode 100644 program/tests/move_lamports.rs diff --git a/program/tests/helpers/instruction_builders.rs b/program/tests/helpers/instruction_builders.rs index eedef07c..5b9caf80 100644 --- a/program/tests/helpers/instruction_builders.rs +++ b/program/tests/helpers/instruction_builders.rs @@ -201,3 +201,73 @@ impl InstructionConfig for MergeConfig<'_> { ] } } + +pub struct MoveLamportsConfig<'a> { + pub source: (&'a Pubkey, &'a AccountSharedData), + pub destination: (&'a Pubkey, &'a AccountSharedData), + pub amount: u64, + /// Override signer for testing wrong signer scenarios (defaults to ctx.staker) + pub override_signer: Option<&'a Pubkey>, +} + +impl<'a> MoveLamportsConfig<'a> { + /// Helper to get the default source vote account from context + pub fn with_default_vote(self, ctx: &'a StakeTestContext) -> MoveLamportsFullConfig<'a> { + MoveLamportsFullConfig { + source: self.source, + destination: self.destination, + override_signer: self.override_signer, + amount: self.amount, + source_vote: ( + ctx.vote_account.as_ref().expect("vote_account required"), + ctx.vote_account_data + .as_ref() + .expect("vote_account_data required"), + ), + dest_vote: None, + } + } +} + +impl InstructionConfig for MoveLamportsConfig<'_> { + fn build_instruction(&self, ctx: &StakeTestContext) -> Instruction { + let signer = self.override_signer.unwrap_or(&ctx.staker); + ixn::move_lamports(self.source.0, self.destination.0, signer, self.amount) + } + + fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)> { + vec![ + (*self.source.0, self.source.1.clone()), + (*self.destination.0, self.destination.1.clone()), + ] + } +} + +pub struct MoveLamportsFullConfig<'a> { + pub source: (&'a Pubkey, &'a AccountSharedData), + pub destination: (&'a Pubkey, &'a AccountSharedData), + pub amount: u64, + /// Override signer for testing wrong signer scenarios (defaults to ctx.staker) + pub override_signer: Option<&'a Pubkey>, + pub source_vote: (&'a Pubkey, &'a AccountSharedData), + pub dest_vote: Option<(&'a Pubkey, &'a AccountSharedData)>, +} + +impl InstructionConfig for MoveLamportsFullConfig<'_> { + fn build_instruction(&self, ctx: &StakeTestContext) -> Instruction { + let signer = self.override_signer.unwrap_or(&ctx.staker); + ixn::move_lamports(self.source.0, self.destination.0, signer, self.amount) + } + + fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)> { + let mut accounts = vec![ + (*self.source.0, self.source.1.clone()), + (*self.destination.0, self.destination.1.clone()), + (*self.source_vote.0, self.source_vote.1.clone()), + ]; + if let Some((vote_pk, vote_acc)) = self.dest_vote { + accounts.push((*vote_pk, vote_acc.clone())); + } + accounts + } +} diff --git a/program/tests/helpers/utils.rs b/program/tests/helpers/utils.rs index ee55f2ff..5b925a4e 100644 --- a/program/tests/helpers/utils.rs +++ b/program/tests/helpers/utils.rs @@ -1,4 +1,5 @@ use { + crate::helpers::{lifecycle::StakeLifecycle, stake_tracker::StakeTracker}, mollusk_svm::Mollusk, solana_account::{Account, AccountSharedData, ReadableAccount, WritableAccount}, solana_clock::Epoch, @@ -126,3 +127,41 @@ pub fn get_effective_stake(mollusk: &Mollusk, stake_account: &AccountSharedData) 0 } } + +/// Synchronize a transient stake's epoch to the current epoch +/// Updates both the account data and the tracker. +pub fn true_up_transient_stake_epoch( + mollusk: &mut Mollusk, + tracker: &mut StakeTracker, + stake_pubkey: &Pubkey, + stake_account: &mut AccountSharedData, + lifecycle: StakeLifecycle, +) { + if lifecycle != StakeLifecycle::Activating && lifecycle != StakeLifecycle::Deactivating { + return; + } + + let clock = mollusk.sysvars.clock.clone(); + let mut stake_state: StakeStateV2 = bincode::deserialize(stake_account.data()).unwrap(); + + if let StakeStateV2::Stake(_, ref mut stake, _) = &mut stake_state { + match lifecycle { + StakeLifecycle::Activating => { + stake.delegation.activation_epoch = clock.epoch; + + // Update tracker as well + if let Some(tracked) = tracker.delegations.get_mut(stake_pubkey) { + tracked.activation_epoch = clock.epoch; + } + } + StakeLifecycle::Deactivating => { + stake.delegation.deactivation_epoch = clock.epoch; + + // Update tracker as well + tracker.track_deactivation(stake_pubkey, clock.epoch); + } + _ => (), + } + } + stake_account.set_data(bincode::serialize(&stake_state).unwrap()); +} diff --git a/program/tests/move_lamports.rs b/program/tests/move_lamports.rs new file mode 100644 index 00000000..54135a65 --- /dev/null +++ b/program/tests/move_lamports.rs @@ -0,0 +1,438 @@ +#![allow(clippy::arithmetic_side_effects)] + +mod helpers; + +use { + helpers::{ + context::StakeTestContext, + instruction_builders::{MoveLamportsConfig, MoveLamportsFullConfig}, + lifecycle::StakeLifecycle, + utils::{get_effective_stake, parse_stake_account, true_up_transient_stake_epoch}, + }, + mollusk_svm::result::Check, + solana_account::WritableAccount, + solana_program_error::ProgramError, + solana_stake_interface::{error::StakeError, state::Lockup}, + test_case::test_matrix, +}; + +#[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] +)] +fn test_move_lamports( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + different_votes: bool, + has_lockup: bool, +) { + let mut ctx = StakeTestContext::new(); + + // Put minimum in both accounts if they're active + let source_staked_amount = if move_source_type == StakeLifecycle::Active { + ctx.minimum_delegation.unwrap() + } else { + 0 + }; + + let dest_staked_amount = if move_dest_type == StakeLifecycle::Active { + ctx.minimum_delegation.unwrap() + } else { + 0 + }; + + // Test with and without lockup + let lockup = if has_lockup { + ctx.create_future_lockup(100) + } else { + Lockup::default() + }; + + // We put an extra minimum in every account, unstaked, to test moving them + let source_excess = ctx.minimum_delegation.unwrap(); + let dest_excess = ctx.minimum_delegation.unwrap(); + + // Dest vote account (possibly different) + let (dest_vote_account, dest_vote_account_data) = if different_votes { + ctx.create_second_vote_account() + } else { + ( + ctx.vote_account.unwrap(), + ctx.vote_account_data.as_ref().unwrap().clone(), + ) + }; + + // Create source and dest stakes + let min_delegation = ctx.minimum_delegation.unwrap(); + let (move_source, mut move_source_account) = ctx + .stake_account(move_source_type) + .staked_amount(min_delegation) + .lockup(&lockup) + .build(); + + let (move_dest, mut move_dest_account) = if different_votes { + // Create with different vote account + ctx.stake_account(move_dest_type) + .staked_amount(min_delegation) + .vote_account(&dest_vote_account) + .lockup(&lockup) + .build() + } else { + ctx.stake_account(move_dest_type) + .staked_amount(min_delegation) + .lockup(&lockup) + .build() + }; + + // True up source epoch if transient (like original test) + // This ensures both stakes are in the current epoch context + true_up_transient_stake_epoch( + &mut ctx.mollusk, + ctx.tracker.as_mut().unwrap(), + &move_source, + &mut move_source_account, + move_source_type, + ); + + // Add excess lamports if Active (like original test) + if move_source_type == StakeLifecycle::Active { + move_source_account + .checked_add_lamports(source_excess) + .unwrap(); + } + if move_dest_type == StakeLifecycle::Active { + move_dest_account.checked_add_lamports(dest_excess).unwrap(); + } + + let source_vote_account_data = ctx.vote_account_data.as_ref().unwrap(); + let source_vote_account = ctx.vote_account.unwrap(); + + // Clear out state failures (activating/deactivating not allowed) + if move_source_type == StakeLifecycle::Activating + || move_source_type == StakeLifecycle::Deactivating + || move_dest_type == StakeLifecycle::Deactivating + { + let result = ctx + .process_with(MoveLamportsFullConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest, &move_dest_account), + override_signer: Some(&ctx.staker), + amount: source_excess, + source_vote: (&source_vote_account, source_vote_account_data), + dest_vote: if different_votes { + Some((&dest_vote_account, &dest_vote_account_data)) + } else { + None + }, + }) + .checks(&[]) + .test_missing_signers(false) + .execute(); + assert!(result.program_result.is_err()); + return; + } + + // Overshoot and fail for underfunded source + ctx.process_with(MoveLamportsFullConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest, &move_dest_account), + override_signer: Some(&ctx.staker), + amount: source_excess + 1, + source_vote: (&source_vote_account, source_vote_account_data), + dest_vote: if different_votes { + Some((&dest_vote_account, &dest_vote_account_data)) + } else { + None + }, + }) + .checks(&[Check::err(ProgramError::InvalidArgument)]) + .test_missing_signers(false) + .execute(); + + let before_source_lamports = parse_stake_account(&move_source_account).2; + let before_dest_lamports = parse_stake_account(&move_dest_account).2; + + // Now properly move the full excess + let result = ctx + .process_with(MoveLamportsFullConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest, &move_dest_account), + override_signer: Some(&ctx.staker), + amount: source_excess, + source_vote: (&source_vote_account, source_vote_account_data), + dest_vote: if different_votes { + Some((&dest_vote_account, &dest_vote_account_data)) + } else { + None + }, + }) + .checks(&[Check::success()]) + .test_missing_signers(true) + .execute(); + + move_source_account = result.resulting_accounts[0].1.clone().into(); + move_dest_account = result.resulting_accounts[1].1.clone().into(); + + let after_source_lamports = parse_stake_account(&move_source_account).2; + let source_effective_stake = get_effective_stake(&ctx.mollusk, &move_source_account); + + // Source activation didn't change + assert_eq!(source_effective_stake, source_staked_amount); + + // Source lamports are right + assert_eq!( + after_source_lamports, + before_source_lamports - ctx.minimum_delegation.unwrap() + ); + assert_eq!( + after_source_lamports, + source_effective_stake + ctx.rent_exempt_reserve + ); + + let after_dest_lamports = parse_stake_account(&move_dest_account).2; + let dest_effective_stake = get_effective_stake(&ctx.mollusk, &move_dest_account); + + // Dest activation didn't change + assert_eq!(dest_effective_stake, dest_staked_amount); + + // Dest lamports are right + assert_eq!( + after_dest_lamports, + before_dest_lamports + ctx.minimum_delegation.unwrap() + ); + assert_eq!( + after_dest_lamports, + dest_effective_stake + ctx.rent_exempt_reserve + source_excess + dest_excess + ); +} + +#[test_matrix( + [(StakeLifecycle::Active, StakeLifecycle::Uninitialized), + (StakeLifecycle::Uninitialized, StakeLifecycle::Initialized), + (StakeLifecycle::Uninitialized, StakeLifecycle::Uninitialized)] +)] +fn test_move_lamports_uninitialized_fail(move_types: (StakeLifecycle, StakeLifecycle)) { + let mut ctx = StakeTestContext::new(); + let source_staked_amount = ctx.minimum_delegation.unwrap() * 2; + let (move_source_type, move_dest_type) = move_types; + + let (move_source, move_source_account) = ctx + .stake_account(move_source_type) + .staked_amount(source_staked_amount) + .build(); + let (move_dest, move_dest_account) = ctx.stake_account(move_dest_type).staked_amount(0).build(); + + let source_signer = if move_source_type == StakeLifecycle::Uninitialized { + move_source + } else { + ctx.staker + }; + + ctx.process_with(MoveLamportsFullConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest, &move_dest_account), + override_signer: Some(&source_signer), + amount: ctx.minimum_delegation.unwrap(), + source_vote: ( + &ctx.vote_account.unwrap(), + ctx.vote_account_data.as_ref().unwrap(), + ), + dest_vote: None, + }) + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) + .execute(); +} + +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Active, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, StakeLifecycle::Deactive] +)] +fn test_move_lamports_general_fail( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, +) { + let mut ctx = StakeTestContext::new(); + let source_staked_amount = ctx.minimum_delegation.unwrap() * 2; + let min_delegation = ctx.minimum_delegation.unwrap(); + let in_force_lockup = ctx.create_in_force_lockup(); + + // Create source + let (move_source, mut move_source_account) = ctx + .stake_account(move_source_type) + .staked_amount(source_staked_amount) + .build(); + move_source_account + .checked_add_lamports(min_delegation) + .unwrap(); + + // Self-move fails + ctx.process_with(MoveLamportsConfig { + source: (&move_source, &move_source_account), + destination: (&move_source, &move_source_account), + override_signer: None, + amount: min_delegation, + }) + .checks(&[Check::err(ProgramError::InvalidInstructionData)]) + .test_missing_signers(false) + .execute(); + + // Zero move fails + let (move_dest, mut move_dest_account) = ctx + .stake_account(move_dest_type) + .staked_amount(min_delegation) + .build(); + + // True up dest epoch if transient + true_up_transient_stake_epoch( + &mut ctx.mollusk, + ctx.tracker.as_mut().unwrap(), + &move_dest, + &mut move_dest_account, + move_dest_type, + ); + + ctx.process_with(MoveLamportsConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest, &move_dest_account), + override_signer: None, + amount: 0, + }) + .checks(&[Check::err(ProgramError::InvalidArgument)]) + .test_missing_signers(false) + .execute(); + + // Sign with withdrawer fails + let withdrawer = ctx.withdrawer; + ctx.process_with(MoveLamportsFullConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest, &move_dest_account), + override_signer: Some(&withdrawer), + amount: min_delegation, + source_vote: ( + &ctx.vote_account.unwrap(), + ctx.vote_account_data.as_ref().unwrap(), + ), + dest_vote: None, + }) + .checks(&[Check::err(ProgramError::MissingRequiredSignature)]) + .test_missing_signers(false) + .execute(); + + // Source lockup fails + let (move_locked_source, mut move_locked_source_account) = ctx + .stake_account(move_source_type) + .staked_amount(source_staked_amount) + .lockup(&in_force_lockup) + .build(); + move_locked_source_account + .checked_add_lamports(min_delegation) + .unwrap(); + + let (move_dest2, move_dest2_account) = ctx + .stake_account(move_dest_type) + .staked_amount(min_delegation) + .build(); + + ctx.process_with(MoveLamportsConfig { + source: (&move_locked_source, &move_locked_source_account), + destination: (&move_dest2, &move_dest2_account), + override_signer: None, + amount: min_delegation, + }) + .checks(&[Check::err(StakeError::MergeMismatch.into())]) + .test_missing_signers(false) + .execute(); + + // Staker mismatch fails + let throwaway_staker = solana_pubkey::Pubkey::new_unique(); + let (move_dest3, move_dest3_account) = ctx + .stake_account(move_dest_type) + .staked_amount(min_delegation) + .stake_authority(&throwaway_staker) + .withdraw_authority(&withdrawer) + .build(); + + ctx.process_with(MoveLamportsConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest3, &move_dest3_account), + override_signer: None, + amount: min_delegation, + }) + .checks(&[Check::err(StakeError::MergeMismatch.into())]) + .test_missing_signers(false) + .execute(); + + // Also verify signing with dest's staker fails (wrong signer for source) + ctx.process_with(MoveLamportsFullConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest3, &move_dest3_account), + override_signer: Some(&throwaway_staker), + amount: min_delegation, + source_vote: ( + &ctx.vote_account.unwrap(), + ctx.vote_account_data.as_ref().unwrap(), + ), + dest_vote: None, + }) + .checks(&[Check::err(ProgramError::MissingRequiredSignature)]) + .test_missing_signers(false) + .execute(); + + // Withdrawer mismatch fails + let throwaway_withdrawer = solana_pubkey::Pubkey::new_unique(); + let staker = ctx.staker; + let (move_dest4, move_dest4_account) = ctx + .stake_account(move_dest_type) + .staked_amount(min_delegation) + .stake_authority(&staker) + .withdraw_authority(&throwaway_withdrawer) + .build(); + + ctx.process_with(MoveLamportsConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest4, &move_dest4_account), + override_signer: None, + amount: min_delegation, + }) + .checks(&[Check::err(StakeError::MergeMismatch.into())]) + .test_missing_signers(false) + .execute(); + + // Also verify signing with dest's withdrawer fails (wrong signer for source) + ctx.process_with(MoveLamportsFullConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest4, &move_dest4_account), + override_signer: Some(&throwaway_withdrawer), + amount: min_delegation, + source_vote: ( + &ctx.vote_account.unwrap(), + ctx.vote_account_data.as_ref().unwrap(), + ), + dest_vote: None, + }) + .checks(&[Check::err(ProgramError::MissingRequiredSignature)]) + .test_missing_signers(false) + .execute(); + + // Dest lockup fails + let (move_dest5, move_dest5_account) = ctx + .stake_account(move_dest_type) + .staked_amount(min_delegation) + .lockup(&in_force_lockup) + .build(); + + ctx.process_with(MoveLamportsConfig { + source: (&move_source, &move_source_account), + destination: (&move_dest5, &move_dest5_account), + override_signer: None, + amount: min_delegation, + }) + .checks(&[Check::err(StakeError::MergeMismatch.into())]) + .test_missing_signers(false) + .execute(); +} diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs index 942fdecf..4e58a7d0 100644 --- a/program/tests/program_test.rs +++ b/program/tests/program_test.rs @@ -1002,566 +1002,3 @@ async fn program_test_move_stake( ); } } - -#[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 program_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, - ); - - process_instruction_test_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 program_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 program_test_move_general_fail( - move_source_type: StakeLifecycle, - 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) - { - 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 - 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::InvalidInstructionData); - - // 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()); - } -} From 69fd2bb2f07abf1e75f93d9819598852d9b56460 Mon Sep 17 00:00:00 2001 From: rustopian <96253492+rustopian@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:39:27 +0000 Subject: [PATCH 2/3] CI --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0959e04d..1700f7f8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: Main on: push: - branches: [main,mollusk-tests-6-merge] + branches: [main,mollusk-tests-7-move-lamports] pull_request: env: From 55d0b850669e399ef385fc4986d4198d77e6850e Mon Sep 17 00:00:00 2001 From: rustopian <96253492+rustopian@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:07:22 +0000 Subject: [PATCH 3/3] style touchups --- program/tests/move_lamports.rs | 66 ++++++++++++++-------------------- program/tests/program_test.rs | 1 - 2 files changed, 27 insertions(+), 40 deletions(-) diff --git a/program/tests/move_lamports.rs b/program/tests/move_lamports.rs index 54135a65..389b6922 100644 --- a/program/tests/move_lamports.rs +++ b/program/tests/move_lamports.rs @@ -31,16 +31,17 @@ fn test_move_lamports( has_lockup: bool, ) { let mut ctx = StakeTestContext::new(); + let min_delegation = ctx.minimum_delegation.unwrap(); // Put minimum in both accounts if they're active let source_staked_amount = if move_source_type == StakeLifecycle::Active { - ctx.minimum_delegation.unwrap() + min_delegation } else { 0 }; let dest_staked_amount = if move_dest_type == StakeLifecycle::Active { - ctx.minimum_delegation.unwrap() + min_delegation } else { 0 }; @@ -53,21 +54,20 @@ fn test_move_lamports( }; // We put an extra minimum in every account, unstaked, to test moving them - let source_excess = ctx.minimum_delegation.unwrap(); - let dest_excess = ctx.minimum_delegation.unwrap(); + let source_excess = min_delegation; + let dest_excess = min_delegation; + + let source_vote_account = ctx.vote_account.unwrap(); + let source_vote_account_data = ctx.vote_account_data.as_ref().unwrap().clone(); // Dest vote account (possibly different) let (dest_vote_account, dest_vote_account_data) = if different_votes { ctx.create_second_vote_account() } else { - ( - ctx.vote_account.unwrap(), - ctx.vote_account_data.as_ref().unwrap().clone(), - ) + (source_vote_account, source_vote_account_data.clone()) }; // Create source and dest stakes - let min_delegation = ctx.minimum_delegation.unwrap(); let (move_source, mut move_source_account) = ctx .stake_account(move_source_type) .staked_amount(min_delegation) @@ -108,9 +108,6 @@ fn test_move_lamports( move_dest_account.checked_add_lamports(dest_excess).unwrap(); } - let source_vote_account_data = ctx.vote_account_data.as_ref().unwrap(); - let source_vote_account = ctx.vote_account.unwrap(); - // Clear out state failures (activating/deactivating not allowed) if move_source_type == StakeLifecycle::Activating || move_source_type == StakeLifecycle::Deactivating @@ -122,7 +119,7 @@ fn test_move_lamports( destination: (&move_dest, &move_dest_account), override_signer: Some(&ctx.staker), amount: source_excess, - source_vote: (&source_vote_account, source_vote_account_data), + source_vote: (&source_vote_account, &source_vote_account_data), dest_vote: if different_votes { Some((&dest_vote_account, &dest_vote_account_data)) } else { @@ -142,7 +139,7 @@ fn test_move_lamports( destination: (&move_dest, &move_dest_account), override_signer: Some(&ctx.staker), amount: source_excess + 1, - source_vote: (&source_vote_account, source_vote_account_data), + source_vote: (&source_vote_account, &source_vote_account_data), dest_vote: if different_votes { Some((&dest_vote_account, &dest_vote_account_data)) } else { @@ -163,7 +160,7 @@ fn test_move_lamports( destination: (&move_dest, &move_dest_account), override_signer: Some(&ctx.staker), amount: source_excess, - source_vote: (&source_vote_account, source_vote_account_data), + source_vote: (&source_vote_account, &source_vote_account_data), dest_vote: if different_votes { Some((&dest_vote_account, &dest_vote_account_data)) } else { @@ -186,7 +183,7 @@ fn test_move_lamports( // Source lamports are right assert_eq!( after_source_lamports, - before_source_lamports - ctx.minimum_delegation.unwrap() + before_source_lamports - min_delegation ); assert_eq!( after_source_lamports, @@ -200,10 +197,7 @@ fn test_move_lamports( assert_eq!(dest_effective_stake, dest_staked_amount); // Dest lamports are right - assert_eq!( - after_dest_lamports, - before_dest_lamports + ctx.minimum_delegation.unwrap() - ); + assert_eq!(after_dest_lamports, before_dest_lamports + min_delegation); assert_eq!( after_dest_lamports, dest_effective_stake + ctx.rent_exempt_reserve + source_excess + dest_excess @@ -217,7 +211,8 @@ fn test_move_lamports( )] fn test_move_lamports_uninitialized_fail(move_types: (StakeLifecycle, StakeLifecycle)) { let mut ctx = StakeTestContext::new(); - let source_staked_amount = ctx.minimum_delegation.unwrap() * 2; + let min_delegation = ctx.minimum_delegation.unwrap(); + let source_staked_amount = min_delegation * 2; let (move_source_type, move_dest_type) = move_types; let (move_source, move_source_account) = ctx @@ -232,15 +227,15 @@ fn test_move_lamports_uninitialized_fail(move_types: (StakeLifecycle, StakeLifec ctx.staker }; + let vote_account = ctx.vote_account.unwrap(); + let vote_account_data = ctx.vote_account_data.as_ref().unwrap().clone(); + ctx.process_with(MoveLamportsFullConfig { source: (&move_source, &move_source_account), destination: (&move_dest, &move_dest_account), override_signer: Some(&source_signer), - amount: ctx.minimum_delegation.unwrap(), - source_vote: ( - &ctx.vote_account.unwrap(), - ctx.vote_account_data.as_ref().unwrap(), - ), + amount: min_delegation, + source_vote: (&vote_account, &vote_account_data), dest_vote: None, }) .checks(&[Check::err(ProgramError::InvalidAccountData)]) @@ -257,8 +252,10 @@ fn test_move_lamports_general_fail( move_dest_type: StakeLifecycle, ) { let mut ctx = StakeTestContext::new(); - let source_staked_amount = ctx.minimum_delegation.unwrap() * 2; let min_delegation = ctx.minimum_delegation.unwrap(); + let source_staked_amount = min_delegation * 2; + let vote_account = ctx.vote_account.unwrap(); + let vote_account_data = ctx.vote_account_data.as_ref().unwrap().clone(); let in_force_lockup = ctx.create_in_force_lockup(); // Create source @@ -313,10 +310,7 @@ fn test_move_lamports_general_fail( destination: (&move_dest, &move_dest_account), override_signer: Some(&withdrawer), amount: min_delegation, - source_vote: ( - &ctx.vote_account.unwrap(), - ctx.vote_account_data.as_ref().unwrap(), - ), + source_vote: (&vote_account, &vote_account_data), dest_vote: None, }) .checks(&[Check::err(ProgramError::MissingRequiredSignature)]) @@ -373,10 +367,7 @@ fn test_move_lamports_general_fail( destination: (&move_dest3, &move_dest3_account), override_signer: Some(&throwaway_staker), amount: min_delegation, - source_vote: ( - &ctx.vote_account.unwrap(), - ctx.vote_account_data.as_ref().unwrap(), - ), + source_vote: (&vote_account, &vote_account_data), dest_vote: None, }) .checks(&[Check::err(ProgramError::MissingRequiredSignature)]) @@ -409,10 +400,7 @@ fn test_move_lamports_general_fail( destination: (&move_dest4, &move_dest4_account), override_signer: Some(&throwaway_withdrawer), amount: min_delegation, - source_vote: ( - &ctx.vote_account.unwrap(), - ctx.vote_account_data.as_ref().unwrap(), - ), + source_vote: (&vote_account, &vote_account_data), dest_vote: None, }) .checks(&[Check::err(ProgramError::MissingRequiredSignature)]) diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs index 4e58a7d0..9686f588 100644 --- a/program/tests/program_test.rs +++ b/program/tests/program_test.rs @@ -12,7 +12,6 @@ use { solana_sdk_ids::system_program, solana_signer::Signer, solana_stake_interface::{ - error::StakeError, instruction::{self as ixn, LockupArgs}, program::id, stake_history::StakeHistory,