Skip to content
Closed
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,mollusk-tests-2-deactivate]
branches: [main,mollusk-tests-3-delegate]
pull_request:

env:
Expand Down
1 change: 0 additions & 1 deletion program/tests/deactivate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ fn test_deactivate(activate: bool) {
override_signer: Some(&ctx.withdrawer),
})
.checks(&[Check::err(ProgramError::MissingRequiredSignature)])
.test_missing_signers(false)
.execute();

// Deactivate succeeds
Expand Down
214 changes: 214 additions & 0 deletions program/tests/delegate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#![allow(clippy::arithmetic_side_effects)]

mod helpers;

use {
helpers::{
context::StakeTestContext,
instruction_builders::{DeactivateConfig, DelegateConfig},
lifecycle::StakeLifecycle,
stake_tracker::MolluskStakeExt,
utils::{create_vote_account, increment_vote_account_credits, parse_stake_account},
},
mollusk_svm::result::Check,
solana_account::{AccountSharedData, WritableAccount},
solana_program_error::ProgramError,
solana_pubkey::Pubkey,
solana_stake_interface::{
error::StakeError,
state::{Delegation, Stake, StakeStateV2},
},
solana_stake_program::id,
};

#[test]
fn test_delegate() {
let mut ctx = StakeTestContext::with_delegation();
let vote_account = *ctx.vote_account.as_ref().unwrap();
let mut vote_account_data = ctx.vote_account_data.as_ref().unwrap().clone();
let min_delegation = ctx.minimum_delegation.unwrap();

let vote_state_credits = 100u64;
increment_vote_account_credits(&mut vote_account_data, 0, vote_state_credits);

let (stake, mut stake_account) = ctx
.stake_account(StakeLifecycle::Initialized)
.staked_amount(min_delegation)
.build();

// Delegate stake
let result = ctx
.process_with(DelegateConfig {
stake: (&stake, &stake_account),
vote: (&vote_account, &vote_account_data),
})
.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();

// Verify that delegate() looks right
let clock = ctx.mollusk.sysvars.clock.clone();
let (_, stake_data, _) = parse_stake_account(&stake_account);
assert_eq!(
stake_data.unwrap(),
Stake {
delegation: Delegation {
voter_pubkey: vote_account,
stake: min_delegation,
activation_epoch: clock.epoch,
deactivation_epoch: u64::MAX,
..Delegation::default()
},
credits_observed: vote_state_credits,
}
);

// Advance epoch to activate the stake
let activation_epoch = ctx.mollusk.sysvars.clock.epoch;
ctx.tracker.as_mut().unwrap().track_delegation(
&stake,
min_delegation,
activation_epoch,
&vote_account,
);

let slots_per_epoch = ctx.mollusk.sysvars.epoch_schedule.slots_per_epoch;
let current_slot = ctx.mollusk.sysvars.clock.slot;
ctx.mollusk.warp_to_slot_with_stake_tracking(
ctx.tracker.as_ref().unwrap(),
current_slot + slots_per_epoch,
Some(0),
);

// Verify that delegate fails as stake is active and not deactivating
ctx.process_with(DelegateConfig {
stake: (&stake, &stake_account),
vote: (&vote_account, ctx.vote_account_data.as_ref().unwrap()),
})
.checks(&[Check::err(StakeError::TooSoonToRedelegate.into())])
.test_missing_signers(false)
.execute();

// Deactivate
let result = ctx
.process_with(DeactivateConfig {
stake: (&stake, &stake_account),
override_signer: None,
})
.execute();
stake_account = result.resulting_accounts[0].1.clone().into();

// Create second vote account
let (vote_account2, vote_account2_data) = ctx.create_second_vote_account();

// Verify that delegate to a different vote account fails during deactivation
ctx.process_with(DelegateConfig {
stake: (&stake, &stake_account),
vote: (&vote_account2, &vote_account2_data),
})
.checks(&[Check::err(StakeError::TooSoonToRedelegate.into())])
.test_missing_signers(false)
.execute();

// Verify that delegate succeeds to same vote account when stake is deactivating
let result = ctx
.process_with(DelegateConfig {
stake: (&stake, &stake_account),
vote: (&vote_account, ctx.vote_account_data.as_ref().unwrap()),
})
.execute();
stake_account = result.resulting_accounts[0].1.clone().into();

// Verify that deactivation has been cleared
let (_, stake_data, _) = parse_stake_account(&stake_account);
assert_eq!(stake_data.unwrap().delegation.deactivation_epoch, u64::MAX);

// Verify that delegate to a different vote account fails if stake is still active
ctx.process_with(DelegateConfig {
stake: (&stake, &stake_account),
vote: (&vote_account2, &vote_account2_data),
})
.checks(&[Check::err(StakeError::TooSoonToRedelegate.into())])
.test_missing_signers(false)
.execute();

// Advance epoch again using tracker
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_with_stake_tracking(
ctx.tracker.as_ref().unwrap(),
current_slot + slots_per_epoch,
Some(0),
);

// Delegate still fails after stake is fully activated; redelegate is not supported
let (vote_account2, vote_account2_data) = ctx.create_second_vote_account();

ctx.process_with(DelegateConfig {
stake: (&stake, &stake_account),
vote: (&vote_account2, &vote_account2_data),
})
.checks(&[Check::err(StakeError::TooSoonToRedelegate.into())])
.test_missing_signers(false)
.execute();
}

#[test]
fn test_delegate_fake_vote_account() {
let mut ctx = StakeTestContext::with_delegation();

// Create fake vote account (not owned by vote program)
let fake_vote_account = Pubkey::new_unique();
let mut fake_vote_data = create_vote_account();
fake_vote_data.set_owner(Pubkey::new_unique()); // Wrong owner

let min_delegation = ctx.minimum_delegation.unwrap();
let (stake, stake_account) = ctx
.stake_account(StakeLifecycle::Initialized)
.staked_amount(min_delegation)
.build();

// Try to delegate to fake vote account
ctx.process_with(DelegateConfig {
stake: (&stake, &stake_account),
vote: (&fake_vote_account, &fake_vote_data),
})
.checks(&[Check::err(ProgramError::IncorrectProgramId)])
.test_missing_signers(false)
.execute();
}

#[test]
fn test_delegate_non_stake_account() {
let ctx = StakeTestContext::with_delegation();

// Create a rewards pool account (program-owned but not a stake account)
let rewards_pool = Pubkey::new_unique();
let rewards_pool_data = AccountSharedData::new_data_with_space(
ctx.rent_exempt_reserve,
&StakeStateV2::RewardsPool,
StakeStateV2::size_of(),
&id(),
)
.unwrap();

ctx.process_with(DelegateConfig {
stake: (&rewards_pool, &rewards_pool_data),
vote: (
ctx.vote_account.as_ref().unwrap(),
ctx.vote_account_data.as_ref().unwrap(),
),
})
.checks(&[Check::err(ProgramError::InvalidAccountData)])
.test_missing_signers(false)
.execute();
}
12 changes: 11 additions & 1 deletion program/tests/helpers/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use {
super::{
instruction_builders::{InstructionConfig, InstructionExecution},
lifecycle::StakeLifecycle,
stake_tracker::StakeTracker,
utils::{add_sysvars, create_vote_account, STAKE_RENT_EXEMPTION},
},
mollusk_svm::{result::Check, Mollusk},
Expand Down Expand Up @@ -67,6 +68,10 @@ impl StakeAccountBuilder<'_> {
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,
self.ctx
.tracker
.as_mut()
.expect("tracker required for stake account builder"),
&stake_pubkey,
self.vote_account.as_ref().unwrap_or(
self.ctx
Expand All @@ -85,6 +90,7 @@ impl StakeAccountBuilder<'_> {
}
}

#[allow(dead_code)] // can be removed once later tests are in
pub struct StakeTestContext {
pub mollusk: Mollusk,
pub rent_exempt_reserve: u64,
Expand All @@ -93,8 +99,10 @@ pub struct StakeTestContext {
pub minimum_delegation: Option<u64>,
pub vote_account: Option<Pubkey>,
pub vote_account_data: Option<AccountSharedData>,
pub tracker: Option<StakeTracker>,
}

#[allow(dead_code)] // can be removed once later tests are in
impl StakeTestContext {
pub fn minimal() -> Self {
let mollusk = Mollusk::new(&id(), "solana_stake_program");
Expand All @@ -106,12 +114,14 @@ impl StakeTestContext {
minimum_delegation: None,
vote_account: None,
vote_account_data: None,
tracker: None,
}
}

pub fn with_delegation() -> Self {
let mollusk = Mollusk::new(&id(), "solana_stake_program");
let minimum_delegation = solana_stake_program::get_minimum_delegation();
let tracker: StakeTracker = StakeLifecycle::create_tracker_for_test(minimum_delegation);
Self {
mollusk,
rent_exempt_reserve: STAKE_RENT_EXEMPTION,
Expand All @@ -120,6 +130,7 @@ impl StakeTestContext {
minimum_delegation: Some(minimum_delegation),
vote_account: Some(Pubkey::new_unique()),
vote_account_data: Some(create_vote_account()),
tracker: Some(tracker),
}
}

Expand Down Expand Up @@ -216,7 +227,6 @@ impl StakeTestContext {
.process_and_validate_instruction(instruction, &accounts_with_sysvars, checks)
}
}

impl Default for StakeTestContext {
fn default() -> Self {
Self::new()
Expand Down
34 changes: 22 additions & 12 deletions program/tests/helpers/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
use {
super::utils::{add_sysvars, create_vote_account, STAKE_RENT_EXEMPTION},
super::{
stake_tracker::MolluskStakeExt,
utils::{add_sysvars, create_vote_account, STAKE_RENT_EXEMPTION},
},
crate::helpers::stake_tracker::StakeTracker,
mollusk_svm::Mollusk,
solana_account::{Account, AccountSharedData, WritableAccount},
solana_pubkey::Pubkey,
Expand All @@ -23,12 +27,22 @@ pub enum StakeLifecycle {
}

impl StakeLifecycle {
/// Helper to create tracker with appropriate background stake for tests
/// Returns a tracker seeded with background cluster stake
pub fn create_tracker_for_test(minimum_delegation: u64) -> StakeTracker {
// Use a moderate background stake amount
// This mimics Banks' cluster-wide effective stake from all validators
// Calculation: needs to be >> test stakes to provide stable warmup base
let background_stake = minimum_delegation.saturating_mul(100);
StakeTracker::with_background_stake(background_stake)
}

/// 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
tracker: &mut StakeTracker,
stake_pubkey: &Pubkey,
vote_account: &Pubkey,
staked_amount: u64,
Expand Down Expand Up @@ -91,9 +105,8 @@ impl StakeLifecycle {
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);
let activation_epoch = mollusk.sysvars.clock.epoch;
tracker.track_delegation(stake_pubkey, staked_amount, activation_epoch, vote_account);
}

// Advance epoch to activate if needed (Active and beyond)
Expand All @@ -104,8 +117,7 @@ impl StakeLifecycle {
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);
mollusk.warp_to_slot_with_stake_tracking(tracker, target_slot, Some(0));
}

// Deactivate if needed
Expand All @@ -120,9 +132,8 @@ impl StakeLifecycle {
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);
let deactivation_epoch = mollusk.sysvars.clock.epoch;
tracker.track_deactivation(stake_pubkey, deactivation_epoch);
}

// Advance epoch to fully deactivate if needed (Deactive lifecycle)
Expand All @@ -134,8 +145,7 @@ impl StakeLifecycle {
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);
mollusk.warp_to_slot_with_stake_tracking(tracker, target_slot, Some(0));
}

stake_account
Expand Down
1 change: 1 addition & 0 deletions program/tests/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
pub mod context;
pub mod instruction_builders;
pub mod lifecycle;
pub mod stake_tracker;
pub mod utils;
Loading