Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Main

on:
push:
branches: [main]
branches: [main,mollusk-tests-2-deactivate]
pull_request:

env:
Expand Down
115 changes: 115 additions & 0 deletions program/tests/deactivate.rs
Original file line number Diff line number Diff line change
@@ -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();
}
136 changes: 124 additions & 12 deletions program/tests/helpers/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,174 @@ use {
super::{
instruction_builders::{InstructionConfig, InstructionExecution},
lifecycle::StakeLifecycle,
utils::{add_sysvars, STAKE_RENT_EXEMPTION},
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 {
/// Follows the builder pattern for flexibility and readability
pub struct StakeAccountBuilder<'a> {
ctx: &'a mut StakeTestContext,
lifecycle: StakeLifecycle,
staked_amount: u64,
stake_authority: Option<Pubkey>,
withdraw_authority: Option<Pubkey>,
lockup: Option<Lockup>,
vote_account: Option<Pubkey>,
stake_pubkey: Option<Pubkey>,
}

impl StakeAccountBuilder {
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 = Pubkey::new_unique();
let account = self.lifecycle.create_uninitialized_account();
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)
}
}

/// Consolidated test context for stake account tests
pub struct StakeTestContext {
pub mollusk: Mollusk,
pub rent_exempt_reserve: u64,
pub staker: Pubkey,
pub withdrawer: Pubkey,
pub minimum_delegation: Option<u64>,
pub vote_account: Option<Pubkey>,
pub vote_account_data: Option<AccountSharedData>,
}

impl StakeTestContext {
/// Create a new test context with all standard setup
pub fn new() -> Self {
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
/// This is the primary method for creating stake accounts in tests.
///
/// Example:
/// ```
/// let (stake, account) = ctx
/// .stake_account(StakeLifecycle::Uninitialized)
/// .stake_account(StakeLifecycle::Active)
/// .staked_amount(1_000_000)
/// .build();
/// ```
pub fn stake_account(&mut self, lifecycle: StakeLifecycle) -> StakeAccountBuilder {
StakeAccountBuilder { lifecycle }
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
/// Process an instruction with a config-based approach
pub fn process_with<'b, C: InstructionConfig>(
&self,
config: C,
Expand All @@ -69,7 +181,7 @@ impl StakeTestContext {
)
}

/// Process an instruction with optional missing signer testing
/// Internal helper to process an instruction with optional missing signer testing
pub(crate) fn process_instruction_maybe_test_signers(
&self,
instruction: &Instruction,
Expand Down
35 changes: 35 additions & 0 deletions program/tests/helpers/instruction_builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,38 @@ impl InstructionConfig for InitializeCheckedConfig<'_> {
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()),
]
}
}
Loading