diff --git a/program/tests/helpers/context.rs b/program/tests/helpers/context.rs new file mode 100644 index 00000000..03713b4d --- /dev/null +++ b/program/tests/helpers/context.rs @@ -0,0 +1,112 @@ +use { + super::{ + instruction_builders::{InstructionConfig, InstructionExecution}, + lifecycle::StakeLifecycle, + utils::{add_sysvars, STAKE_RENT_EXEMPTION}, + }, + mollusk_svm::{result::Check, Mollusk}, + solana_account::AccountSharedData, + solana_instruction::Instruction, + solana_pubkey::Pubkey, + solana_stake_program::id, +}; + +/// Builder for creating stake accounts with customizable parameters +pub struct StakeAccountBuilder { + lifecycle: StakeLifecycle, +} + +impl StakeAccountBuilder { + pub fn build(self) -> (Pubkey, AccountSharedData) { + let stake_pubkey = Pubkey::new_unique(); + let account = self.lifecycle.create_uninitialized_account(); + (stake_pubkey, account) + } +} + +/// Consolidated test context for stake account tests +pub struct StakeTestContext { + pub mollusk: Mollusk, + pub rent_exempt_reserve: u64, + pub staker: Pubkey, + pub withdrawer: Pubkey, +} + +impl StakeTestContext { + /// Create a new test context with all standard setup + pub fn new() -> Self { + let mollusk = Mollusk::new(&id(), "solana_stake_program"); + + Self { + mollusk, + rent_exempt_reserve: STAKE_RENT_EXEMPTION, + staker: Pubkey::new_unique(), + withdrawer: Pubkey::new_unique(), + } + } + + /// Create a stake account builder for the specified lifecycle stage + /// + /// Example: + /// ``` + /// let (stake, account) = ctx + /// .stake_account(StakeLifecycle::Uninitialized) + /// .build(); + /// ``` + pub fn stake_account(&mut self, lifecycle: StakeLifecycle) -> StakeAccountBuilder { + StakeAccountBuilder { lifecycle } + } + + /// Process an instruction + pub fn process_with<'b, C: InstructionConfig>( + &self, + config: C, + ) -> InstructionExecution<'_, 'b> { + InstructionExecution::new( + config.build_instruction(self), + config.build_accounts(), + self, + ) + } + + /// Process an instruction with optional missing signer testing + pub(crate) fn process_instruction_maybe_test_signers( + &self, + instruction: &Instruction, + accounts: Vec<(Pubkey, AccountSharedData)>, + checks: &[Check], + test_missing_signers: bool, + ) -> mollusk_svm::result::InstructionResult { + if test_missing_signers { + use solana_program_error::ProgramError; + + // Test that removing each signer causes failure + for i in 0..instruction.accounts.len() { + if instruction.accounts[i].is_signer { + let mut modified_instruction = instruction.clone(); + modified_instruction.accounts[i].is_signer = false; + + let accounts_with_sysvars = + add_sysvars(&self.mollusk, &modified_instruction, accounts.clone()); + + self.mollusk.process_and_validate_instruction( + &modified_instruction, + &accounts_with_sysvars, + &[Check::err(ProgramError::MissingRequiredSignature)], + ); + } + } + } + + // Process with all signers present + let accounts_with_sysvars = add_sysvars(&self.mollusk, instruction, accounts); + self.mollusk + .process_and_validate_instruction(instruction, &accounts_with_sysvars, checks) + } +} + +impl Default for StakeTestContext { + fn default() -> Self { + Self::new() + } +} diff --git a/program/tests/helpers/instruction_builders.rs b/program/tests/helpers/instruction_builders.rs new file mode 100644 index 00000000..515a72a0 --- /dev/null +++ b/program/tests/helpers/instruction_builders.rs @@ -0,0 +1,105 @@ +use { + super::context::StakeTestContext, + mollusk_svm::result::Check, + solana_account::AccountSharedData, + solana_instruction::Instruction, + solana_pubkey::Pubkey, + solana_stake_interface::{ + instruction as ixn, + state::{Authorized, Lockup}, + }, +}; + +// Trait for instruction configuration that builds instruction and accounts +pub trait InstructionConfig { + fn build_instruction(&self, ctx: &StakeTestContext) -> Instruction; + fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)>; +} + +/// Execution builder with validation and signer testing +pub struct InstructionExecution<'a, 'b> { + instruction: Instruction, + accounts: Vec<(Pubkey, AccountSharedData)>, + ctx: &'a StakeTestContext, + checks: Option<&'b [Check<'b>]>, + test_missing_signers: Option, // `None` runs if `Check::success` +} + +impl<'b> InstructionExecution<'_, 'b> { + pub fn checks(mut self, checks: &'b [Check<'b>]) -> Self { + self.checks = Some(checks); + self + } + + pub fn test_missing_signers(mut self, test: bool) -> Self { + self.test_missing_signers = Some(test); + self + } + + /// Executes the instruction. If `checks` is `None` or empty, uses `Check::success()`. + /// 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, + }; + + let test_missing_signers = self.test_missing_signers.unwrap_or(true); + + self.ctx.process_instruction_maybe_test_signers( + &self.instruction, + self.accounts, + checks, + test_missing_signers, + ) + } +} + +impl<'a> InstructionExecution<'a, '_> { + pub(crate) fn new( + instruction: Instruction, + accounts: Vec<(Pubkey, AccountSharedData)>, + ctx: &'a StakeTestContext, + ) -> Self { + Self { + instruction, + accounts, + ctx, + checks: None, + test_missing_signers: None, + } + } +} + +pub struct InitializeConfig<'a> { + pub stake: (&'a Pubkey, &'a AccountSharedData), + pub authorized: &'a Authorized, + pub lockup: &'a Lockup, +} + +impl InstructionConfig for InitializeConfig<'_> { + fn build_instruction(&self, _ctx: &StakeTestContext) -> Instruction { + ixn::initialize(self.stake.0, self.authorized, self.lockup) + } + + fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)> { + vec![(*self.stake.0, self.stake.1.clone())] + } +} + +pub struct InitializeCheckedConfig<'a> { + pub stake: (&'a Pubkey, &'a AccountSharedData), + pub authorized: &'a Authorized, +} + +impl InstructionConfig for InitializeCheckedConfig<'_> { + fn build_instruction(&self, _ctx: &StakeTestContext) -> Instruction { + ixn::initialize_checked(self.stake.0, self.authorized) + } + + fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)> { + vec![(*self.stake.0, self.stake.1.clone())] + } +} diff --git a/program/tests/helpers/lifecycle.rs b/program/tests/helpers/lifecycle.rs new file mode 100644 index 00000000..74e55285 --- /dev/null +++ b/program/tests/helpers/lifecycle.rs @@ -0,0 +1,29 @@ +use { + super::utils::STAKE_RENT_EXEMPTION, solana_account::AccountSharedData, + solana_stake_interface::state::StakeStateV2, solana_stake_program::id, +}; + +/// Lifecycle states for stake accounts in tests +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum StakeLifecycle { + Uninitialized = 0, + Initialized, + Activating, + Active, + Deactivating, + Deactive, + Closed, +} + +impl StakeLifecycle { + /// Create an uninitialized stake account + pub fn create_uninitialized_account(self) -> AccountSharedData { + AccountSharedData::new_data_with_space( + STAKE_RENT_EXEMPTION, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of(), + &id(), + ) + .unwrap() + } +} diff --git a/program/tests/helpers/mod.rs b/program/tests/helpers/mod.rs new file mode 100644 index 00000000..ff09b000 --- /dev/null +++ b/program/tests/helpers/mod.rs @@ -0,0 +1,7 @@ +#![allow(clippy::arithmetic_side_effects)] +#![allow(dead_code)] + +pub mod context; +pub mod instruction_builders; +pub mod lifecycle; +pub mod utils; diff --git a/program/tests/helpers/utils.rs b/program/tests/helpers/utils.rs new file mode 100644 index 00000000..3bc0659b --- /dev/null +++ b/program/tests/helpers/utils.rs @@ -0,0 +1,65 @@ +use { + mollusk_svm::Mollusk, + solana_account::{Account, AccountSharedData}, + solana_instruction::Instruction, + solana_pubkey::Pubkey, + solana_rent::Rent, + solana_stake_interface::{stake_history::StakeHistory, state::StakeStateV2}, + solana_sysvar_id::SysvarId, + std::collections::HashMap, +}; + +// hardcoded for convenience +pub const STAKE_RENT_EXEMPTION: u64 = 2_282_880; + +#[test] +fn assert_stake_rent_exemption() { + assert_eq!( + Rent::default().minimum_balance(StakeStateV2::size_of()), + STAKE_RENT_EXEMPTION + ); +} + +/// Resolve all accounts for an instruction, including sysvars and instruction accounts +/// +/// This function re-serializes the stake history sysvar from mollusk.sysvars.stake_history +/// every time it's called, ensuring that any updates to the stake history are reflected in the accounts. +pub fn add_sysvars( + mollusk: &Mollusk, + instruction: &Instruction, + accounts: Vec<(Pubkey, AccountSharedData)>, +) -> Vec<(Pubkey, Account)> { + // Build a map of provided accounts + let mut account_map: HashMap = accounts + .into_iter() + .map(|(pk, acc)| (pk, acc.into())) + .collect(); + + // Now resolve all accounts from the instruction + let mut result = Vec::new(); + for account_meta in &instruction.accounts { + let key = account_meta.pubkey; + let account = if let Some(acc) = account_map.remove(&key) { + // Use the provided account + acc + } else if Rent::check_id(&key) { + mollusk.sysvars.keyed_account_for_rent_sysvar().1 + } else if solana_clock::Clock::check_id(&key) { + mollusk.sysvars.keyed_account_for_clock_sysvar().1 + } else if solana_epoch_schedule::EpochSchedule::check_id(&key) { + mollusk.sysvars.keyed_account_for_epoch_schedule_sysvar().1 + } else if solana_epoch_rewards::EpochRewards::check_id(&key) { + mollusk.sysvars.keyed_account_for_epoch_rewards_sysvar().1 + } else if StakeHistory::check_id(&key) { + // Re-serialize stake history from mollusk.sysvars.stake_history + mollusk.sysvars.keyed_account_for_stake_history_sysvar().1 + } else { + // Default empty account + Account::default() + }; + + result.push((key, account)); + } + + result +} diff --git a/program/tests/initialize.rs b/program/tests/initialize.rs new file mode 100644 index 00000000..a3d52f94 --- /dev/null +++ b/program/tests/initialize.rs @@ -0,0 +1,269 @@ +#![allow(clippy::arithmetic_side_effects)] + +mod helpers; + +use { + helpers::{ + context::StakeTestContext, + instruction_builders::{InitializeCheckedConfig, InitializeConfig}, + lifecycle::StakeLifecycle, + }, + mollusk_svm::result::Check, + solana_account::{AccountSharedData, ReadableAccount}, + solana_program_error::ProgramError, + solana_pubkey::Pubkey, + solana_rent::Rent, + solana_sdk_ids::{stake::id, system_program::id as system_program_id}, + solana_stake_interface::state::{Authorized, Lockup, StakeStateV2}, + test_case::test_case, +}; + +#[derive(Debug, Clone, Copy)] +enum InitializeVariant { + Initialize, + InitializeChecked, +} + +fn lockup_for(variant: InitializeVariant, custodian: Pubkey) -> Lockup { + match variant { + InitializeVariant::Initialize => Lockup { + epoch: 1, + unix_timestamp: 0, + custodian, + }, + InitializeVariant::InitializeChecked => Lockup::default(), + } +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize(variant: InitializeVariant) { + let mut ctx = StakeTestContext::new(); + + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + let lockup = lockup_for(variant, Pubkey::new_unique()); + + let (stake, stake_account) = ctx.stake_account(StakeLifecycle::Uninitialized).build(); + + let result = { + let program_id = id(); + let checks = [ + Check::success(), + Check::all_rent_exempt(), + Check::account(&stake) + .lamports(ctx.rent_exempt_reserve) + .owner(&program_id) + .space(StakeStateV2::size_of()) + .build(), + ]; + + let processor = match variant { + InitializeVariant::Initialize => ctx.process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }), + InitializeVariant::InitializeChecked => ctx.process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }), + }; + + processor + .checks(&checks) + .test_missing_signers(true) + .execute() + }; + + let resulting_account: AccountSharedData = result.resulting_accounts[0].1.clone().into(); + let stake_state: StakeStateV2 = bincode::deserialize(resulting_account.data()).unwrap(); + assert_eq!( + stake_state, + StakeStateV2::Initialized(solana_stake_interface::state::Meta { + authorized, + rent_exempt_reserve: ctx.rent_exempt_reserve, + lockup, + }), + ); + + // Re-initialize should fail + let processor = match variant { + InitializeVariant::Initialize => ctx.process_with(InitializeConfig { + stake: (&stake, &resulting_account), + authorized: &authorized, + lockup: &lockup, + }), + InitializeVariant::InitializeChecked => ctx.process_with(InitializeCheckedConfig { + stake: (&stake, &resulting_account), + authorized: &authorized, + }), + }; + + processor + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) + .execute(); +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_insufficient_funds(variant: InitializeVariant) { + let ctx = StakeTestContext::new(); + + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + let lockup = lockup_for(variant, Pubkey::new_unique()); + + // Account has insufficient lamports + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + ctx.rent_exempt_reserve / 2, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of(), + &id(), + ) + .unwrap(); + + let processor = match variant { + InitializeVariant::Initialize => ctx.process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }), + InitializeVariant::InitializeChecked => ctx.process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }), + }; + + processor + .checks(&[Check::err(ProgramError::InsufficientFunds)]) + .test_missing_signers(false) + .execute(); +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_incorrect_size_larger(variant: InitializeVariant) { + let ctx = StakeTestContext::new(); + + let rent_exempt_reserve = Rent::default().minimum_balance(StakeStateV2::size_of() * 2); + + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + let lockup = lockup_for(variant, Pubkey::new_unique()); + + // Account data length too large + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + rent_exempt_reserve, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of() + 1, + &id(), + ) + .unwrap(); + + let processor = match variant { + InitializeVariant::Initialize => ctx.process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }), + InitializeVariant::InitializeChecked => ctx.process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }), + }; + + processor + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) + .execute(); +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_incorrect_size_smaller(variant: InitializeVariant) { + let ctx = StakeTestContext::new(); + + let rent_exempt_reserve = Rent::default().minimum_balance(StakeStateV2::size_of()); + + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + let lockup = lockup_for(variant, Pubkey::new_unique()); + + // Account data length too small + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + rent_exempt_reserve, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of() - 1, + &id(), + ) + .unwrap(); + + let processor = match variant { + InitializeVariant::Initialize => ctx.process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }), + InitializeVariant::InitializeChecked => ctx.process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }), + }; + + processor + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) + .execute(); +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_wrong_owner(variant: InitializeVariant) { + let ctx = StakeTestContext::new(); + + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + let lockup = lockup_for(variant, Pubkey::new_unique()); + + // Owner is not the stake program + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + Rent::default().minimum_balance(StakeStateV2::size_of()), + &StakeStateV2::Uninitialized, + StakeStateV2::size_of(), + &system_program_id(), + ) + .unwrap(); + + let processor = match variant { + InitializeVariant::Initialize => ctx.process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }), + InitializeVariant::InitializeChecked => ctx.process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }), + }; + + processor + .checks(&[Check::err(ProgramError::InvalidAccountOwner)]) + .test_missing_signers(false) + .execute(); +} diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs index e1988c7a..4f146806 100644 --- a/program/tests/program_test.rs +++ b/program/tests/program_test.rs @@ -396,17 +396,6 @@ async fn program_test_stake_checked_instructions() { let seed = "test seed"; let seeded_address = Pubkey::create_with_seed(&seed_base, seed, &system_program::id()).unwrap(); - // Test InitializeChecked with non-signing withdrawer - let stake = create_blank_stake_account(&mut context).await; - let instruction = ixn::initialize_checked(&stake, &Authorized { staker, withdrawer }); - - process_instruction_test_missing_signers( - &mut context, - &instruction, - &vec![&withdrawer_keypair], - ) - .await; - // Test AuthorizeChecked with non-signing staker let stake = create_independent_stake_account(&mut context, &Authorized { staker, withdrawer }, 0).await; @@ -482,116 +471,6 @@ async fn program_test_stake_checked_instructions() { .await; } -#[tokio::test] -async fn program_test_stake_initialize() { - 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 staker_keypair = Keypair::new(); - let withdrawer_keypair = Keypair::new(); - let custodian_keypair = Keypair::new(); - - let staker = staker_keypair.pubkey(); - let withdrawer = withdrawer_keypair.pubkey(); - let custodian = custodian_keypair.pubkey(); - - let authorized = Authorized { staker, withdrawer }; - - let lockup = Lockup { - epoch: 1, - unix_timestamp: 0, - custodian, - }; - - let stake = create_blank_stake_account(&mut context).await; - let instruction = ixn::initialize(&stake, &authorized, &lockup); - - // should pass - process_instruction(&mut context, &instruction, NO_SIGNERS) - .await - .unwrap(); - - // check that we see what we expect - let account = get_account(&mut context.banks_client, &stake).await; - let stake_state: StakeStateV2 = bincode::deserialize(&account.data).unwrap(); - assert_eq!( - stake_state, - StakeStateV2::Initialized(Meta { - authorized, - rent_exempt_reserve, - lockup, - }), - ); - - // 2nd time fails, can't move it from anything other than uninit->init - refresh_blockhash(&mut context).await; - let e = process_instruction(&mut context, &instruction, NO_SIGNERS) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::InvalidAccountData); - - // not enough balance for rent - let stake = Pubkey::new_unique(); - let account = SolanaAccount { - lamports: rent_exempt_reserve / 2, - data: vec![0; StakeStateV2::size_of()], - owner: id(), - executable: false, - rent_epoch: 1000, - }; - context.set_account(&stake, &account.into()); - - let instruction = ixn::initialize(&stake, &authorized, &lockup); - let e = process_instruction(&mut context, &instruction, NO_SIGNERS) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::InsufficientFunds); - - // incorrect account sizes - let stake_keypair = Keypair::new(); - let stake = stake_keypair.pubkey(); - - let instruction = system_instruction::create_account( - &context.payer.pubkey(), - &stake, - rent_exempt_reserve * 2, - StakeStateV2::size_of() as u64 + 1, - &id(), - ); - process_instruction(&mut context, &instruction, &vec![&stake_keypair]) - .await - .unwrap(); - - let instruction = ixn::initialize(&stake, &authorized, &lockup); - let e = process_instruction(&mut context, &instruction, NO_SIGNERS) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::InvalidAccountData); - - let stake_keypair = Keypair::new(); - let stake = stake_keypair.pubkey(); - - let instruction = system_instruction::create_account( - &context.payer.pubkey(), - &stake, - rent_exempt_reserve, - StakeStateV2::size_of() as u64 - 1, - &id(), - ); - process_instruction(&mut context, &instruction, &vec![&stake_keypair]) - .await - .unwrap(); - - let instruction = ixn::initialize(&stake, &authorized, &lockup); - let e = process_instruction(&mut context, &instruction, NO_SIGNERS) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::InvalidAccountData); -} - #[tokio::test] async fn program_test_authorize() { let mut context = program_test().start_with_context().await;