From a6cb4fe13915faaf0a69e79d96cf778cb43cfeec Mon Sep 17 00:00:00 2001 From: rustopian <96253492+rustopian@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:09:35 +0000 Subject: [PATCH] migrate Merge tests --- .github/workflows/main.yml | 2 +- program/tests/helpers/instruction_builders.rs | 26 +++- program/tests/merge.rs | 109 ++++++++++++++++ program/tests/program_test.rs | 121 ------------------ 4 files changed, 133 insertions(+), 125 deletions(-) create mode 100644 program/tests/merge.rs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ab203e68..0959e04d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: Main on: push: - branches: [main,mollusk-tests-5-withdraw] + branches: [main,mollusk-tests-6-merge] pull_request: env: diff --git a/program/tests/helpers/instruction_builders.rs b/program/tests/helpers/instruction_builders.rs index 3249e61e..eedef07c 100644 --- a/program/tests/helpers/instruction_builders.rs +++ b/program/tests/helpers/instruction_builders.rs @@ -36,14 +36,15 @@ impl<'b> InstructionExecution<'_, 'b> { self } - /// Executes the instruction. If `checks` is `None` or empty, uses `Check::success()`. + /// Executes the instruction. If `checks` is `None`, uses `Check::success()`. + /// If `checks` is `Some(&[])` (explicitly empty), performs no checks. /// Fail-safe default: when `test_missing_signers` is `None`, runs the missing-signers /// test (`true`). Callers must explicitly opt out with `.test_missing_signers(false)`. pub fn execute(self) -> mollusk_svm::result::InstructionResult { let default_checks = [Check::success()]; let checks = match self.checks { - Some(c) if !c.is_empty() => c, - _ => &default_checks, + Some(c) => c, + None => &default_checks, }; let test_missing_signers = self.test_missing_signers.unwrap_or(true); @@ -181,3 +182,22 @@ impl InstructionConfig for WithdrawConfig<'_> { ] } } + +pub struct MergeConfig<'a> { + pub destination: (&'a Pubkey, &'a AccountSharedData), + pub source: (&'a Pubkey, &'a AccountSharedData), +} + +impl InstructionConfig for MergeConfig<'_> { + fn build_instruction(&self, ctx: &StakeTestContext) -> Instruction { + let instructions = ixn::merge(self.destination.0, self.source.0, &ctx.staker); + instructions[0].clone() // Merge returns a Vec, use first instruction + } + + fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)> { + vec![ + (*self.destination.0, self.destination.1.clone()), + (*self.source.0, self.source.1.clone()), + ] + } +} diff --git a/program/tests/merge.rs b/program/tests/merge.rs new file mode 100644 index 00000000..625a34f6 --- /dev/null +++ b/program/tests/merge.rs @@ -0,0 +1,109 @@ +#![allow(clippy::arithmetic_side_effects)] + +mod helpers; + +use { + helpers::{ + context::StakeTestContext, instruction_builders::MergeConfig, lifecycle::StakeLifecycle, + }, + mollusk_svm::result::Check, + solana_account::ReadableAccount, + solana_stake_interface::state::StakeStateV2, + solana_stake_program::id, + test_case::test_matrix, +}; + +#[test_matrix( + [StakeLifecycle::Uninitialized, StakeLifecycle::Initialized, StakeLifecycle::Activating, + StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [StakeLifecycle::Uninitialized, StakeLifecycle::Initialized, StakeLifecycle::Activating, + StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive] +)] +fn test_merge(merge_source_type: StakeLifecycle, merge_dest_type: StakeLifecycle) { + let mut ctx = StakeTestContext::new(); + + let staked_amount = ctx.minimum_delegation; + + // Determine if merge should be allowed based on lifecycle types + let is_merge_allowed_by_type = match (merge_source_type, merge_dest_type) { + // Inactive and inactive + (StakeLifecycle::Initialized, StakeLifecycle::Initialized) + | (StakeLifecycle::Initialized, StakeLifecycle::Deactive) + | (StakeLifecycle::Deactive, StakeLifecycle::Initialized) + | (StakeLifecycle::Deactive, StakeLifecycle::Deactive) => true, + + // Activating into inactive is also allowed + (StakeLifecycle::Activating, StakeLifecycle::Initialized) + | (StakeLifecycle::Activating, StakeLifecycle::Deactive) => true, + + // Inactive into activating + (StakeLifecycle::Initialized, StakeLifecycle::Activating) + | (StakeLifecycle::Deactive, StakeLifecycle::Activating) => true, + + // Active and active + (StakeLifecycle::Active, StakeLifecycle::Active) => true, + + // Activating and activating + (StakeLifecycle::Activating, StakeLifecycle::Activating) => true, + + // Everything else fails + _ => false, + }; + + // Create source and dest accounts + let (merge_source, mut merge_source_account) = ctx + .stake_account(merge_source_type) + .staked_amount(staked_amount.unwrap()) + .build(); + let (merge_dest, merge_dest_account) = ctx + .stake_account(merge_dest_type) + .staked_amount(staked_amount.unwrap()) + .build(); + + // Retrieve source data and sync epochs if needed + let mut source_stake_state: StakeStateV2 = + bincode::deserialize(merge_source_account.data()).unwrap(); + + let clock = ctx.mollusk.sysvars.clock.clone(); + // Sync epochs for transient states + if let StakeStateV2::Stake(_, ref mut stake, _) = &mut source_stake_state { + match merge_source_type { + StakeLifecycle::Activating => stake.delegation.activation_epoch = clock.epoch, + StakeLifecycle::Deactivating => stake.delegation.deactivation_epoch = clock.epoch, + _ => (), + } + } + + // Store updated source + merge_source_account.set_data(bincode::serialize(&source_stake_state).unwrap()); + + // Attempt to merge + if is_merge_allowed_by_type { + ctx.process_with(MergeConfig { + destination: (&merge_dest, &merge_dest_account), + source: (&merge_source, &merge_source_account), + }) + .checks(&[ + Check::success(), + Check::account(&merge_dest) + .lamports(staked_amount.unwrap() * 2 + ctx.rent_exempt_reserve * 2) + .owner(&id()) + .space(StakeStateV2::size_of()) + .rent_exempt() + .build(), + ]) + .test_missing_signers(true) + .execute(); + } else { + // Various errors can occur for invalid merges, we just check it fails + let result = ctx + .process_with(MergeConfig { + destination: (&merge_dest, &merge_dest_account), + source: (&merge_source, &merge_source_account), + }) + .checks(&[]) // Skip Success check + .test_missing_signers(false) + .execute(); + assert!(result.program_result.is_err()); + } +} diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs index e50891c8..942fdecf 100644 --- a/program/tests/program_test.rs +++ b/program/tests/program_test.rs @@ -750,127 +750,6 @@ impl StakeLifecycle { } } -// XXX the original test_merge is a stupid test -// the real thing is test_merge_active_stake which actively controls clock and -// stake_history but im just trying to smoke test rn so lets do something -// simpler -#[test_matrix( - [StakeLifecycle::Uninitialized, StakeLifecycle::Initialized, StakeLifecycle::Activating, - StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive], - [StakeLifecycle::Uninitialized, StakeLifecycle::Initialized, StakeLifecycle::Activating, - StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive] -)] -#[tokio::test] -async fn program_test_merge(merge_source_type: StakeLifecycle, merge_dest_type: StakeLifecycle) { - 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; - let staked_amount = minimum_delegation; - - // stake accounts can be merged unconditionally: - // * inactive and inactive - // * inactive into activating - // can be merged IF vote pubkey and credits match: - // * active and active - // * activating and activating, IF activating in the same epoch - // in all cases, authorized and lockup also must match - // uninitialized stakes cannot be merged at all - let is_merge_allowed_by_type = match (merge_source_type, merge_dest_type) { - // inactive and inactive - (StakeLifecycle::Initialized, StakeLifecycle::Initialized) - | (StakeLifecycle::Initialized, StakeLifecycle::Deactive) - | (StakeLifecycle::Deactive, StakeLifecycle::Initialized) - | (StakeLifecycle::Deactive, StakeLifecycle::Deactive) => true, - - // activating into inactive is also allowed although this isnt clear from docs - (StakeLifecycle::Activating, StakeLifecycle::Initialized) - | (StakeLifecycle::Activating, StakeLifecycle::Deactive) => true, - - // inactive into activating - (StakeLifecycle::Initialized, StakeLifecycle::Activating) - | (StakeLifecycle::Deactive, StakeLifecycle::Activating) => true, - - // active and active - (StakeLifecycle::Active, StakeLifecycle::Active) => true, - - // activating and activating - (StakeLifecycle::Activating, StakeLifecycle::Activating) => true, - - // better luck next time - _ => false, - }; - - // create source first - let (merge_source_keypair, _, _) = merge_source_type - .new_stake_account(&mut context, &accounts.vote_account.pubkey(), staked_amount) - .await; - let merge_source = merge_source_keypair.pubkey(); - - // retrieve its data - let mut source_account = get_account(&mut context.banks_client, &merge_source).await; - let mut source_stake_state: StakeStateV2 = bincode::deserialize(&source_account.data).unwrap(); - - // create dest. this may mess source up if its in a transient state, but its - // fine - let (merge_dest_keypair, staker_keypair, withdrawer_keypair) = merge_dest_type - .new_stake_account(&mut context, &accounts.vote_account.pubkey(), staked_amount) - .await; - let merge_dest = merge_dest_keypair.pubkey(); - - // now we change source authorized to match dest - // we can also true up the epoch if source should have been transient - let clock = context.banks_client.get_sysvar::().await.unwrap(); - match &mut source_stake_state { - StakeStateV2::Initialized(ref mut meta) => { - meta.authorized.staker = staker_keypair.pubkey(); - meta.authorized.withdrawer = withdrawer_keypair.pubkey(); - } - StakeStateV2::Stake(ref mut meta, ref mut stake, _) => { - meta.authorized.staker = staker_keypair.pubkey(); - meta.authorized.withdrawer = withdrawer_keypair.pubkey(); - - match merge_source_type { - StakeLifecycle::Activating => stake.delegation.activation_epoch = clock.epoch, - StakeLifecycle::Deactivating => stake.delegation.deactivation_epoch = clock.epoch, - _ => (), - } - } - _ => (), - } - - // and store - source_account.data = bincode::serialize(&source_stake_state).unwrap(); - context.set_account(&merge_source, &source_account.into()); - - // attempt to merge - let instruction = ixn::merge(&merge_dest, &merge_source, &staker_keypair.pubkey()) - .into_iter() - .next() - .unwrap(); - - // failure can result in various different errors... dont worry about it for now - if is_merge_allowed_by_type { - process_instruction_test_missing_signers( - &mut context, - &instruction, - &vec![&staker_keypair], - ) - .await; - - let dest_lamports = get_account(&mut context.banks_client, &merge_dest) - .await - .lamports; - assert_eq!(dest_lamports, staked_amount * 2 + rent_exempt_reserve * 2); - } else { - process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - } -} - #[test_matrix( [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive],