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..96c4346c 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 = "1" [lib] crate-type = ["cdylib", "lib"] diff --git a/program/tests/deactivate.rs b/program/tests/deactivate.rs new file mode 100644 index 00000000..980edaab --- /dev/null +++ b/program/tests/deactivate.rs @@ -0,0 +1,115 @@ +#![allow(clippy::arithmetic_side_effects)] + +mod helpers; + +use { + helpers::{ + context::StakeTestContext, + instruction_builders::{DeactivateConfig, DelegateConfig}, + lifecycle::StakeLifecycle, + utils::parse_stake_account, + }, + mollusk_svm::result::Check, + solana_program_error::ProgramError, + solana_stake_interface::{error::StakeError, state::StakeStateV2}, + solana_stake_program::id, + test_case::test_case, +}; + +#[test_case(false; "activating")] +#[test_case(true; "active")] +fn test_deactivate(activate: bool) { + let mut ctx = StakeTestContext::with_delegation(); + let min_delegation = ctx.minimum_delegation.unwrap(); + + let (stake, mut stake_account) = ctx + .stake_account(StakeLifecycle::Initialized) + .staked_amount(min_delegation) + .build(); + + // Deactivating an undelegated account fails + ctx.process_with(DeactivateConfig { + stake: (&stake, &stake_account), + override_signer: None, + }) + .checks(&[Check::err(ProgramError::InvalidAccountData)]) + .test_missing_signers(false) + .execute(); + + // Delegate + let result = ctx + .process_with(DelegateConfig { + stake: (&stake, &stake_account), + vote: ( + ctx.vote_account.as_ref().unwrap(), + ctx.vote_account_data.as_ref().unwrap(), + ), + }) + .execute(); + stake_account = result.resulting_accounts[0].1.clone().into(); + + if activate { + // Advance epoch to activate + let current_slot = ctx.mollusk.sysvars.clock.slot; + let slots_per_epoch = ctx.mollusk.sysvars.epoch_schedule.slots_per_epoch; + ctx.mollusk.warp_to_slot(current_slot + slots_per_epoch); + } + + // Deactivate with withdrawer fails + ctx.process_with(DeactivateConfig { + stake: (&stake, &stake_account), + override_signer: Some(&ctx.withdrawer), + }) + .checks(&[Check::err(ProgramError::MissingRequiredSignature)]) + .test_missing_signers(false) + .execute(); + + // Deactivate succeeds + let result = ctx + .process_with(DeactivateConfig { + stake: (&stake, &stake_account), + override_signer: None, + }) + .checks(&[ + Check::success(), + Check::all_rent_exempt(), + Check::account(&stake) + .lamports(ctx.rent_exempt_reserve + min_delegation) + .owner(&id()) + .space(StakeStateV2::size_of()) + .build(), + ]) + .test_missing_signers(true) + .execute(); + stake_account = result.resulting_accounts[0].1.clone().into(); + + let clock = ctx.mollusk.sysvars.clock.clone(); + let (_, stake_data, _) = parse_stake_account(&stake_account); + assert_eq!( + stake_data.unwrap().delegation.deactivation_epoch, + clock.epoch + ); + + // Deactivate again fails + ctx.process_with(DeactivateConfig { + stake: (&stake, &stake_account), + override_signer: None, + }) + .checks(&[Check::err(StakeError::AlreadyDeactivated.into())]) + .test_missing_signers(false) + .execute(); + + // Advance epoch + let current_slot = ctx.mollusk.sysvars.clock.slot; + let slots_per_epoch = ctx.mollusk.sysvars.epoch_schedule.slots_per_epoch; + ctx.mollusk.warp_to_slot(current_slot + slots_per_epoch); + + // Deactivate again still fails + ctx.process_with(DeactivateConfig { + stake: (&stake, &stake_account), + override_signer: None, + }) + .checks(&[Check::err(StakeError::AlreadyDeactivated.into())]) + .test_missing_signers(false) + .execute(); +} diff --git a/program/tests/helpers/context.rs b/program/tests/helpers/context.rs new file mode 100644 index 00000000..d0c44350 --- /dev/null +++ b/program/tests/helpers/context.rs @@ -0,0 +1,222 @@ +use { + super::{ + instruction_builders::{InstructionConfig, InstructionExecution}, + lifecycle::StakeLifecycle, + utils::{add_sysvars, create_vote_account, STAKE_RENT_EXEMPTION}, + }, + mollusk_svm::{result::Check, Mollusk}, + solana_account::AccountSharedData, + solana_instruction::Instruction, + solana_pubkey::Pubkey, + solana_stake_interface::state::Lockup, + solana_stake_program::id, +}; + +/// Builder for creating stake accounts with customizable parameters +pub struct StakeAccountBuilder<'a> { + ctx: &'a mut StakeTestContext, + lifecycle: StakeLifecycle, + staked_amount: u64, + stake_authority: Option, + withdraw_authority: Option, + lockup: Option, + vote_account: Option, + stake_pubkey: Option, +} + +impl StakeAccountBuilder<'_> { + /// Set the staked amount (lamports delegated to validator) + pub fn staked_amount(mut self, amount: u64) -> Self { + self.staked_amount = amount; + self + } + + /// Set a custom stake authority (defaults to ctx.staker) + pub fn stake_authority(mut self, authority: &Pubkey) -> Self { + self.stake_authority = Some(*authority); + self + } + + /// Set a custom withdraw authority (defaults to ctx.withdrawer) + pub fn withdraw_authority(mut self, authority: &Pubkey) -> Self { + self.withdraw_authority = Some(*authority); + self + } + + /// Set a custom lockup (defaults to Lockup::default()) + pub fn lockup(mut self, lockup: &Lockup) -> Self { + self.lockup = Some(*lockup); + self + } + + /// Set a custom vote account (defaults to ctx.vote_account) + pub fn vote_account(mut self, vote_account: &Pubkey) -> Self { + self.vote_account = Some(*vote_account); + self + } + + /// Set a specific stake account pubkey (defaults to Pubkey::new_unique()) + pub fn stake_pubkey(mut self, pubkey: &Pubkey) -> Self { + self.stake_pubkey = Some(*pubkey); + self + } + + /// Build the stake account and return (pubkey, account_data) + pub fn build(self) -> (Pubkey, AccountSharedData) { + let stake_pubkey = self.stake_pubkey.unwrap_or_else(Pubkey::new_unique); + let account = self.lifecycle.create_stake_account_fully_specified( + &mut self.ctx.mollusk, + &stake_pubkey, + self.vote_account.as_ref().unwrap_or( + self.ctx + .vote_account + .as_ref() + .expect("vote_account required for this lifecycle"), + ), + self.staked_amount, + self.stake_authority.as_ref().unwrap_or(&self.ctx.staker), + self.withdraw_authority + .as_ref() + .unwrap_or(&self.ctx.withdrawer), + self.lockup.as_ref().unwrap_or(&Lockup::default()), + ); + (stake_pubkey, account) + } +} + +pub struct StakeTestContext { + pub mollusk: Mollusk, + pub rent_exempt_reserve: u64, + pub staker: Pubkey, + pub withdrawer: Pubkey, + pub minimum_delegation: Option, + pub vote_account: Option, + pub vote_account_data: Option, +} + +impl StakeTestContext { + pub fn minimal() -> 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(), + minimum_delegation: None, + vote_account: None, + vote_account_data: None, + } + } + + pub fn with_delegation() -> Self { + let mollusk = Mollusk::new(&id(), "solana_stake_program"); + let minimum_delegation = solana_stake_program::get_minimum_delegation(); + Self { + mollusk, + rent_exempt_reserve: STAKE_RENT_EXEMPTION, + staker: Pubkey::new_unique(), + withdrawer: Pubkey::new_unique(), + minimum_delegation: Some(minimum_delegation), + vote_account: Some(Pubkey::new_unique()), + vote_account_data: Some(create_vote_account()), + } + } + + pub fn new() -> Self { + Self::with_delegation() + } + + /// Create a stake account builder for the specified lifecycle stage + /// + /// Example: + /// ``` + /// let (stake, account) = ctx + /// .stake_account(StakeLifecycle::Active) + /// .staked_amount(1_000_000) + /// .build(); + /// ``` + pub fn stake_account(&mut self, lifecycle: StakeLifecycle) -> StakeAccountBuilder { + StakeAccountBuilder { + ctx: self, + lifecycle, + staked_amount: 0, + stake_authority: None, + withdraw_authority: None, + lockup: None, + vote_account: None, + stake_pubkey: None, + } + } + + /// Create a lockup that expires in the future + pub fn create_future_lockup(&self, epochs_ahead: u64) -> Lockup { + Lockup { + unix_timestamp: 0, + epoch: self.mollusk.sysvars.clock.epoch + epochs_ahead, + custodian: Pubkey::new_unique(), + } + } + + /// Create a lockup that's currently in force (far future) + pub fn create_in_force_lockup(&self) -> Lockup { + self.create_future_lockup(1_000_000) + } + + /// Create a second vote account (for testing different vote accounts) + pub fn create_second_vote_account(&self) -> (Pubkey, AccountSharedData) { + (Pubkey::new_unique(), create_vote_account()) + } + + /// Process an instruction with a config-based approach + 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..cc700115 --- /dev/null +++ b/program/tests/helpers/instruction_builders.rs @@ -0,0 +1,140 @@ +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()`. + /// When `test_missing_signers` is `None`, runs the missing-signers tests. + /// 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())] + } +} + +pub struct DeactivateConfig<'a> { + pub stake: (&'a Pubkey, &'a AccountSharedData), + /// Override signer for testing wrong signer scenarios (defaults to ctx.staker) + pub override_signer: Option<&'a Pubkey>, +} + +impl InstructionConfig for DeactivateConfig<'_> { + fn build_instruction(&self, ctx: &StakeTestContext) -> Instruction { + let signer = self.override_signer.unwrap_or(&ctx.staker); + ixn::deactivate_stake(self.stake.0, signer) + } + + fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)> { + vec![(*self.stake.0, self.stake.1.clone())] + } +} + +pub struct DelegateConfig<'a> { + pub stake: (&'a Pubkey, &'a AccountSharedData), + pub vote: (&'a Pubkey, &'a AccountSharedData), +} + +impl InstructionConfig for DelegateConfig<'_> { + fn build_instruction(&self, ctx: &StakeTestContext) -> Instruction { + ixn::delegate_stake(self.stake.0, &ctx.staker, self.vote.0) + } + + fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)> { + vec![ + (*self.stake.0, self.stake.1.clone()), + (*self.vote.0, self.vote.1.clone()), + ] + } +} diff --git a/program/tests/helpers/lifecycle.rs b/program/tests/helpers/lifecycle.rs new file mode 100644 index 00000000..e094c96f --- /dev/null +++ b/program/tests/helpers/lifecycle.rs @@ -0,0 +1,156 @@ +use { + super::utils::{add_sysvars, create_vote_account, STAKE_RENT_EXEMPTION}, + mollusk_svm::Mollusk, + solana_account::{Account, AccountSharedData, WritableAccount}, + solana_pubkey::Pubkey, + solana_stake_interface::{ + instruction as ixn, + state::{Authorized, Lockup, 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 a stake account with full specification of authorities and lockup + #[allow(clippy::too_many_arguments)] + pub fn create_stake_account_fully_specified( + self, + mollusk: &mut Mollusk, + // tracker: &mut StakeTracker, // added in subsequent PR + stake_pubkey: &Pubkey, + vote_account: &Pubkey, + staked_amount: u64, + staker: &Pubkey, + withdrawer: &Pubkey, + lockup: &Lockup, + ) -> AccountSharedData { + let is_closed = self == StakeLifecycle::Closed; + + // Create base account + let mut stake_account = if is_closed { + let mut account = Account::create(STAKE_RENT_EXEMPTION, vec![], id(), false, u64::MAX); + // Add staked_amount even for closed accounts (matches program-test behavior) + if staked_amount > 0 { + account.lamports += staked_amount; + } + account.into() + } else { + Account::create( + STAKE_RENT_EXEMPTION + staked_amount, + vec![0; StakeStateV2::size_of()], + id(), + false, + u64::MAX, + ) + .into() + }; + + if is_closed { + return stake_account; + } + + let authorized = Authorized { + staker: *staker, + withdrawer: *withdrawer, + }; + + // Initialize if needed + if self >= StakeLifecycle::Initialized { + let stake_state = StakeStateV2::Initialized(solana_stake_interface::state::Meta { + rent_exempt_reserve: STAKE_RENT_EXEMPTION, + authorized, + lockup: *lockup, + }); + bincode::serialize_into(stake_account.data_as_mut_slice(), &stake_state).unwrap(); + } + + // Delegate if needed + if self >= StakeLifecycle::Activating { + let instruction = ixn::delegate_stake(stake_pubkey, staker, vote_account); + + let accounts = vec![ + (*stake_pubkey, stake_account.clone()), + (*vote_account, create_vote_account()), + ]; + + // Use add_sysvars to provide clock, stake history, and config accounts + let accounts_with_sysvars = add_sysvars(mollusk, &instruction, accounts); + let result = mollusk.process_instruction(&instruction, &accounts_with_sysvars); + stake_account = result.resulting_accounts[0].1.clone().into(); + + // Track delegation in the tracker + // let activation_epoch = mollusk.sysvars.clock.epoch; + // TODO: uncomment in subsequent PR (add `tracker.track_delegation` here) + // tracker.track_delegation(stake_pubkey, staked_amount, activation_epoch, vote_account); + } + + // Advance epoch to activate if needed (Active and beyond) + if self >= StakeLifecycle::Active { + // With background stake in tracker, just warp 1 epoch + // The background stake provides baseline for instant partial activation + let slots_per_epoch = mollusk.sysvars.epoch_schedule.slots_per_epoch; + let current_slot = mollusk.sysvars.clock.slot; + let target_slot = current_slot + slots_per_epoch; + + // TODO: use `warp_to_slot_with_stake_tracking` here (in subsequent PR) + mollusk.warp_to_slot(target_slot); + } + + // Deactivate if needed + if self >= StakeLifecycle::Deactivating { + let instruction = ixn::deactivate_stake(stake_pubkey, staker); + + let accounts = vec![(*stake_pubkey, stake_account.clone())]; + + // Use add_sysvars to provide clock account + let accounts_with_sysvars = add_sysvars(mollusk, &instruction, accounts); + let result = mollusk.process_instruction(&instruction, &accounts_with_sysvars); + stake_account = result.resulting_accounts[0].1.clone().into(); + + // Track deactivation in the tracker + // let deactivation_epoch = mollusk.sysvars.clock.epoch; + // TODO: uncomment in subsequent PR + // tracker.track_deactivation(stake_pubkey, deactivation_epoch); + } + + // Advance epoch to fully deactivate if needed (Deactive lifecycle) + // Matches program_test.rs line 978-983: advance_epoch once to fully deactivate + if self == StakeLifecycle::Deactive { + // With background stake, advance 1 epoch for deactivation + // Background provides the baseline for instant partial deactivation + let slots_per_epoch = mollusk.sysvars.epoch_schedule.slots_per_epoch; + let current_slot = mollusk.sysvars.clock.slot; + let target_slot = current_slot + slots_per_epoch; + + // TODO: use `warp_to_slot_with_stake_tracking` here (in subsequent PR) + mollusk.warp_to_slot(target_slot); + } + + stake_account + } + + /// Whether this lifecycle stage enforces minimum delegation for split + pub fn split_minimum_enforced(&self) -> bool { + matches!( + self, + Self::Activating | Self::Active | Self::Deactivating | Self::Deactive + ) + } + + /// Whether this lifecycle stage enforces minimum delegation for withdraw + pub fn withdraw_minimum_enforced(&self) -> bool { + matches!(self, Self::Activating | Self::Active | Self::Deactivating) + } +} 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..3113d245 --- /dev/null +++ b/program/tests/helpers/utils.rs @@ -0,0 +1,94 @@ +use { + mollusk_svm::Mollusk, + solana_account::{Account, AccountSharedData, ReadableAccount, WritableAccount}, + solana_instruction::Instruction, + solana_pubkey::Pubkey, + solana_rent::Rent, + solana_stake_interface::{stake_history::StakeHistory, state::StakeStateV2}, + solana_sysvar_id::SysvarId, + solana_vote_interface::state::{VoteStateV4, VoteStateVersions}, + 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 +} + +/// Create a vote account with VoteStateV4 +pub fn create_vote_account() -> AccountSharedData { + let space = VoteStateV4::size_of(); + let lamports = Rent::default().minimum_balance(space); + let vote_state = VoteStateVersions::new_v4(VoteStateV4::default()); + let data = bincode::serialize(&vote_state).unwrap(); + + Account::create(lamports, data, solana_sdk_ids::vote::id(), false, u64::MAX).into() +} + +/// Parse a stake account into (Meta, Option, lamports) +pub fn parse_stake_account( + stake_account: &AccountSharedData, +) -> ( + solana_stake_interface::state::Meta, + Option, + u64, +) { + let lamports = stake_account.lamports(); + let stake_state: StakeStateV2 = bincode::deserialize(stake_account.data()).unwrap(); + + match stake_state { + StakeStateV2::Initialized(meta) => (meta, None, lamports), + StakeStateV2::Stake(meta, stake, _) => (meta, Some(stake), lamports), + _ => panic!("Expected initialized or staked account"), + } +} diff --git a/program/tests/initialize.rs b/program/tests/initialize.rs new file mode 100644 index 00000000..75187774 --- /dev/null +++ b/program/tests/initialize.rs @@ -0,0 +1,256 @@ +#![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_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(), + }; + + let (stake, stake_account) = ctx.stake_account(StakeLifecycle::Uninitialized).build(); + + // Process the Initialize instruction, including testing missing signers + 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() + }; + + // 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 + 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 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 (manually 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(); + + 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 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(); + + 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 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(); + + 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(); +} diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs index e1988c7a..7c6dc660 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; @@ -1327,81 +1206,6 @@ async fn program_test_withdraw_stake(withdraw_source_type: StakeLifecycle) { assert_eq!(e, ProgramError::InvalidAccountData); } -#[test_case(false; "activating")] -#[test_case(true; "active")] -#[tokio::test] -async fn program_test_deactivate(activate: 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 staker_keypair = Keypair::new(); - let withdrawer_keypair = Keypair::new(); - - let staker = staker_keypair.pubkey(); - let withdrawer = withdrawer_keypair.pubkey(); - - let authorized = Authorized { staker, withdrawer }; - - let stake = - create_independent_stake_account(&mut context, &authorized, minimum_delegation).await; - - // deactivating an undelegated account fails - let instruction = ixn::deactivate_stake(&stake, &staker); - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::InvalidAccountData); - - // delegate - let instruction = ixn::delegate_stake(&stake, &staker, &accounts.vote_account.pubkey()); - process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap(); - - if activate { - advance_epoch(&mut context).await; - } else { - refresh_blockhash(&mut context).await; - } - - // deactivate with withdrawer fails - let instruction = ixn::deactivate_stake(&stake, &withdrawer); - let e = process_instruction(&mut context, &instruction, &vec![&withdrawer_keypair]) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::MissingRequiredSignature); - - // deactivate succeeds - let instruction = ixn::deactivate_stake(&stake, &staker); - process_instruction_test_missing_signers(&mut context, &instruction, &vec![&staker_keypair]) - .await; - - let clock = context.banks_client.get_sysvar::().await.unwrap(); - let (_, stake_data, _) = get_stake_account(&mut context.banks_client, &stake).await; - assert_eq!( - stake_data.unwrap().delegation.deactivation_epoch, - clock.epoch - ); - - // deactivate again fails - refresh_blockhash(&mut context).await; - - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, StakeError::AlreadyDeactivated.into()); - - advance_epoch(&mut context).await; - - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, StakeError::AlreadyDeactivated.into()); -} - // 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