From 274de12088989e9a40a0ffe05b69278cc78cf9c6 Mon Sep 17 00:00:00 2001 From: rustopian <96253492+rustopian@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:37:55 +0100 Subject: [PATCH 1/4] migrated Initialize and InitializeChecked tests to mollusk StakeTestContext to reduce boilerplate InstructionConfig builder pattern improve InitializeChecked coverage --- Cargo.lock | 1 + program/Cargo.toml | 1 + program/tests/helpers/context.rs | 112 ++++++++ program/tests/helpers/instruction_builders.rs | 96 +++++++ program/tests/helpers/lifecycle.rs | 30 ++ program/tests/helpers/mod.rs | 10 + program/tests/helpers/utils.rs | 65 +++++ program/tests/initialize.rs | 270 ++++++++++++++++++ program/tests/program_test.rs | 110 ------- 9 files changed, 585 insertions(+), 110 deletions(-) create mode 100644 program/tests/helpers/context.rs create mode 100644 program/tests/helpers/instruction_builders.rs create mode 100644 program/tests/helpers/lifecycle.rs create mode 100644 program/tests/helpers/mod.rs create mode 100644 program/tests/helpers/utils.rs create mode 100644 program/tests/initialize.rs diff --git a/Cargo.lock b/Cargo.lock index 2876dd23..eed57071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7214,6 +7214,7 @@ dependencies = [ "solana-transaction", "solana-vote-interface 4.0.4", "test-case", + "tokio", ] [[package]] diff --git a/program/Cargo.toml b/program/Cargo.toml index e47d083b..00823837 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -45,6 +45,7 @@ solana-system-interface = { version = "2.0.0", features = ["bincode"] } solana-sysvar-id = "3.0.0" solana-transaction = "3.0.0" test-case = "3.3.1" +tokio = { version = "1", features = ["full"] } [lib] crate-type = ["cdylib", "lib"] 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..59795fe1 --- /dev/null +++ b/program/tests/helpers/instruction_builders.rs @@ -0,0 +1,96 @@ +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: bool, +} + +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) -> Self { + self.test_missing_signers = true; + self + } + + pub fn execute(self) -> mollusk_svm::result::InstructionResult { + let default_checks = [Check::success()]; + let checks = self.checks.unwrap_or(&default_checks); + self.ctx.process_instruction_maybe_test_signers( + &self.instruction, + self.accounts, + checks, + self.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: false, + } + } +} + +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..85656537 --- /dev/null +++ b/program/tests/helpers/lifecycle.rs @@ -0,0 +1,30 @@ +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 +#[allow(dead_code)] +#[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..7b7f988e --- /dev/null +++ b/program/tests/helpers/mod.rs @@ -0,0 +1,10 @@ +#![allow(clippy::arithmetic_side_effects)] + +pub mod context; +pub mod instruction_builders; +pub mod lifecycle; +pub mod utils; + +pub use { + context::StakeTestContext, instruction_builders::InitializeConfig, lifecycle::StakeLifecycle, +}; 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..d05bd6a4 --- /dev/null +++ b/program/tests/initialize.rs @@ -0,0 +1,270 @@ +#![allow(clippy::arithmetic_side_effects)] + +mod helpers; + +use { + crate::helpers::instruction_builders::InitializeCheckedConfig, + helpers::{InitializeConfig, StakeTestContext}, + mollusk_svm::result::Check, + solana_account::{AccountSharedData, ReadableAccount}, + solana_program_error::ProgramError, + solana_pubkey::Pubkey, + solana_rent::Rent, + solana_stake_interface::state::{Authorized, Lockup, StakeStateV2}, + solana_stake_program::id, + test_case::test_case, +}; + +#[derive(Debug, Clone, Copy)] +enum InitializeVariant { + Initialize, + InitializeChecked, +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize(variant: InitializeVariant) { + let mut ctx = StakeTestContext::new(); + + let custodian = Pubkey::new_unique(); + + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + + // InitializeChecked always uses default lockup + let lockup = match variant { + InitializeVariant::Initialize => Lockup { + epoch: 1, + unix_timestamp: 0, + custodian, + }, + InitializeVariant::InitializeChecked => Lockup::default(), + }; + + // Create an uninitialized stake account + let (stake, stake_account) = ctx + .stake_account(helpers::StakeLifecycle::Uninitialized) + .build(); + + // Process the Initialize instruction, including testing missing signers + let result = match variant { + InitializeVariant::Initialize => ctx + .process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }) + .checks(&[ + Check::success(), + Check::all_rent_exempt(), + Check::account(&stake) + .lamports(ctx.rent_exempt_reserve) + .owner(&id()) + .space(StakeStateV2::size_of()) + .build(), + ]) + .test_missing_signers() + .execute(), + InitializeVariant::InitializeChecked => ctx + .process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }) + .checks(&[ + Check::success(), + Check::all_rent_exempt(), + Check::account(&stake) + .lamports(ctx.rent_exempt_reserve) + .owner(&id()) + .space(StakeStateV2::size_of()) + .build(), + ]) + .test_missing_signers() + .execute(), + }; + + // Check that we see what we expect + 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, + }), + ); + + // Attempting to initialize an already initialized stake account should fail + match variant { + InitializeVariant::Initialize => ctx + .process_with(InitializeConfig { + stake: (&stake, &resulting_account), + authorized: &authorized, + lockup: &lockup, + }) + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .execute(), + InitializeVariant::InitializeChecked => ctx + .process_with(InitializeCheckedConfig { + stake: (&stake, &resulting_account), + authorized: &authorized, + }) + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .execute(), + }; +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_insufficient_funds(variant: InitializeVariant) { + let ctx = StakeTestContext::new(); + + let custodian = Pubkey::new_unique(); + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + let lockup = match variant { + InitializeVariant::Initialize => Lockup { + epoch: 1, + unix_timestamp: 0, + custodian, + }, + InitializeVariant::InitializeChecked => Lockup::default(), + }; + + // Create account with insufficient lamports (need to manually create since builder adds rent automatically) + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + ctx.rent_exempt_reserve / 2, // Not enough lamports + &StakeStateV2::Uninitialized, + StakeStateV2::size_of(), + &id(), + ) + .unwrap(); + + match variant { + InitializeVariant::Initialize => ctx + .process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }) + .checks(&[Check::err(ProgramError::InsufficientFunds)]) + .execute(), + InitializeVariant::InitializeChecked => ctx + .process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }) + .checks(&[Check::err(ProgramError::InsufficientFunds)]) + .execute(), + }; +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_incorrect_size_larger(variant: InitializeVariant) { + let ctx = StakeTestContext::new(); + + // Original program_test.rs uses double rent instead of just + // increasing the size by 1. This behavior remains (makes no difference here). + let rent_exempt_reserve = Rent::default().minimum_balance(StakeStateV2::size_of() * 2); + + let custodian = Pubkey::new_unique(); + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + let lockup = match variant { + InitializeVariant::Initialize => Lockup { + epoch: 1, + unix_timestamp: 0, + custodian, + }, + InitializeVariant::InitializeChecked => Lockup::default(), + }; + + // Create account with wrong size (need to manually create since builder enforces correct size) + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + rent_exempt_reserve, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of() + 1, // Too large + &id(), + ) + .unwrap(); + + match variant { + InitializeVariant::Initialize => ctx + .process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }) + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .execute(), + InitializeVariant::InitializeChecked => ctx + .process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }) + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .execute(), + }; +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_incorrect_size_smaller(variant: InitializeVariant) { + let ctx = StakeTestContext::new(); + + // Original program_test.rs uses rent for size instead of + // rent for size - 1. This behavior remains (makes no difference here). + let rent_exempt_reserve = Rent::default().minimum_balance(StakeStateV2::size_of()); + + let custodian = Pubkey::new_unique(); + let authorized = Authorized { + staker: ctx.staker, + withdrawer: ctx.withdrawer, + }; + let lockup = match variant { + InitializeVariant::Initialize => Lockup { + epoch: 1, + unix_timestamp: 0, + custodian, + }, + InitializeVariant::InitializeChecked => Lockup::default(), + }; + + // Create account with wrong size (need to manually create since builder enforces correct size) + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + rent_exempt_reserve, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of() - 1, // Too small + &id(), + ) + .unwrap(); + + match variant { + InitializeVariant::Initialize => ctx + .process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }) + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .execute(), + InitializeVariant::InitializeChecked => ctx + .process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }) + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .execute(), + }; +} diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs index e1988c7a..3b60aaee 100644 --- a/program/tests/program_test.rs +++ b/program/tests/program_test.rs @@ -482,116 +482,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; From 58d7c99cb06f429beffe7df8c4f89fe5441fa27b Mon Sep 17 00:00:00 2001 From: Peter Keay <96253492+rustopian@users.noreply.github.com> Date: Sat, 1 Nov 2025 13:11:37 +0000 Subject: [PATCH 2/4] test_missing_signers by default --- program/tests/helpers/instruction_builders.rs | 21 +++++++++++++------ program/tests/initialize.rs | 12 +++++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/program/tests/helpers/instruction_builders.rs b/program/tests/helpers/instruction_builders.rs index 59795fe1..515a72a0 100644 --- a/program/tests/helpers/instruction_builders.rs +++ b/program/tests/helpers/instruction_builders.rs @@ -22,7 +22,7 @@ pub struct InstructionExecution<'a, 'b> { accounts: Vec<(Pubkey, AccountSharedData)>, ctx: &'a StakeTestContext, checks: Option<&'b [Check<'b>]>, - test_missing_signers: bool, + test_missing_signers: Option, // `None` runs if `Check::success` } impl<'b> InstructionExecution<'_, 'b> { @@ -31,19 +31,28 @@ impl<'b> InstructionExecution<'_, 'b> { self } - pub fn test_missing_signers(mut self) -> Self { - self.test_missing_signers = true; + 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 = self.checks.unwrap_or(&default_checks); + 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, - self.test_missing_signers, + test_missing_signers, ) } } @@ -59,7 +68,7 @@ impl<'a> InstructionExecution<'a, '_> { accounts, ctx, checks: None, - test_missing_signers: false, + test_missing_signers: None, } } } diff --git a/program/tests/initialize.rs b/program/tests/initialize.rs index d05bd6a4..dcb95cbc 100644 --- a/program/tests/initialize.rs +++ b/program/tests/initialize.rs @@ -65,7 +65,7 @@ fn test_initialize(variant: InitializeVariant) { .space(StakeStateV2::size_of()) .build(), ]) - .test_missing_signers() + .test_missing_signers(true) .execute(), InitializeVariant::InitializeChecked => ctx .process_with(InitializeCheckedConfig { @@ -81,7 +81,7 @@ fn test_initialize(variant: InitializeVariant) { .space(StakeStateV2::size_of()) .build(), ]) - .test_missing_signers() + .test_missing_signers(true) .execute(), }; @@ -106,6 +106,7 @@ fn test_initialize(variant: InitializeVariant) { lockup: &lockup, }) .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) .execute(), InitializeVariant::InitializeChecked => ctx .process_with(InitializeCheckedConfig { @@ -113,6 +114,7 @@ fn test_initialize(variant: InitializeVariant) { authorized: &authorized, }) .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) .execute(), }; } @@ -154,6 +156,7 @@ fn test_initialize_insufficient_funds(variant: InitializeVariant) { lockup: &lockup, }) .checks(&[Check::err(ProgramError::InsufficientFunds)]) + .test_missing_signers(false) .execute(), InitializeVariant::InitializeChecked => ctx .process_with(InitializeCheckedConfig { @@ -161,6 +164,7 @@ fn test_initialize_insufficient_funds(variant: InitializeVariant) { authorized: &authorized, }) .checks(&[Check::err(ProgramError::InsufficientFunds)]) + .test_missing_signers(false) .execute(), }; } @@ -206,6 +210,7 @@ fn test_initialize_incorrect_size_larger(variant: InitializeVariant) { lockup: &lockup, }) .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) .execute(), InitializeVariant::InitializeChecked => ctx .process_with(InitializeCheckedConfig { @@ -213,6 +218,7 @@ fn test_initialize_incorrect_size_larger(variant: InitializeVariant) { authorized: &authorized, }) .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) .execute(), }; } @@ -258,6 +264,7 @@ fn test_initialize_incorrect_size_smaller(variant: InitializeVariant) { lockup: &lockup, }) .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) .execute(), InitializeVariant::InitializeChecked => ctx .process_with(InitializeCheckedConfig { @@ -265,6 +272,7 @@ fn test_initialize_incorrect_size_smaller(variant: InitializeVariant) { authorized: &authorized, }) .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) .execute(), }; } From fffaf274ce17c0d76056cac67d406ab4ccc7c93c Mon Sep 17 00:00:00 2001 From: rustopian <96253492+rustopian@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:56:54 +0000 Subject: [PATCH 3/4] rm unnecessary re-exports --- program/tests/helpers/mod.rs | 4 ---- program/tests/initialize.rs | 11 ++++++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/program/tests/helpers/mod.rs b/program/tests/helpers/mod.rs index 7b7f988e..fac852a0 100644 --- a/program/tests/helpers/mod.rs +++ b/program/tests/helpers/mod.rs @@ -4,7 +4,3 @@ pub mod context; pub mod instruction_builders; pub mod lifecycle; pub mod utils; - -pub use { - context::StakeTestContext, instruction_builders::InitializeConfig, lifecycle::StakeLifecycle, -}; diff --git a/program/tests/initialize.rs b/program/tests/initialize.rs index dcb95cbc..cc62b280 100644 --- a/program/tests/initialize.rs +++ b/program/tests/initialize.rs @@ -3,8 +3,11 @@ mod helpers; use { - crate::helpers::instruction_builders::InitializeCheckedConfig, - helpers::{InitializeConfig, StakeTestContext}, + helpers::{ + context::StakeTestContext, + instruction_builders::{InitializeCheckedConfig, InitializeConfig}, + lifecycle::StakeLifecycle, + }, mollusk_svm::result::Check, solana_account::{AccountSharedData, ReadableAccount}, solana_program_error::ProgramError, @@ -44,9 +47,7 @@ fn test_initialize(variant: InitializeVariant) { }; // Create an uninitialized stake account - let (stake, stake_account) = ctx - .stake_account(helpers::StakeLifecycle::Uninitialized) - .build(); + let (stake, stake_account) = ctx.stake_account(StakeLifecycle::Uninitialized).build(); // Process the Initialize instruction, including testing missing signers let result = match variant { From 6d33056974689567459e923e18ca43a206950e6e Mon Sep 17 00:00:00 2001 From: rustopian <96253492+rustopian@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:26:59 +0000 Subject: [PATCH 4/4] TODO comment --- program/tests/helpers/lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/program/tests/helpers/lifecycle.rs b/program/tests/helpers/lifecycle.rs index 85656537..231958f0 100644 --- a/program/tests/helpers/lifecycle.rs +++ b/program/tests/helpers/lifecycle.rs @@ -4,7 +4,7 @@ use { }; /// Lifecycle states for stake accounts in tests -#[allow(dead_code)] +#[allow(dead_code)] // TODO: remove once tests include all lifecycles #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum StakeLifecycle { Uninitialized = 0,