diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 69f6be2f..e74d0610 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: Main on: push: - branches: [main,mollusk-tests-2-deactivate] + branches: [main,mollusk-tests-3-delegate] pull_request: env: diff --git a/program/tests/deactivate.rs b/program/tests/deactivate.rs index 980edaab..c4daaa18 100644 --- a/program/tests/deactivate.rs +++ b/program/tests/deactivate.rs @@ -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 diff --git a/program/tests/delegate.rs b/program/tests/delegate.rs new file mode 100644 index 00000000..eb65b5dd --- /dev/null +++ b/program/tests/delegate.rs @@ -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(); +} diff --git a/program/tests/helpers/context.rs b/program/tests/helpers/context.rs index 9c7579e0..dd2fbbfc 100644 --- a/program/tests/helpers/context.rs +++ b/program/tests/helpers/context.rs @@ -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}, @@ -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 @@ -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, @@ -93,8 +99,10 @@ pub struct StakeTestContext { pub minimum_delegation: Option, pub vote_account: Option, pub vote_account_data: Option, + pub tracker: Option, } +#[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"); @@ -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, @@ -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), } } @@ -216,7 +227,6 @@ impl StakeTestContext { .process_and_validate_instruction(instruction, &accounts_with_sysvars, checks) } } - impl Default for StakeTestContext { fn default() -> Self { Self::new() diff --git a/program/tests/helpers/lifecycle.rs b/program/tests/helpers/lifecycle.rs index e094c96f..67aed6a9 100644 --- a/program/tests/helpers/lifecycle.rs +++ b/program/tests/helpers/lifecycle.rs @@ -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, @@ -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, @@ -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) @@ -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 @@ -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) @@ -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 diff --git a/program/tests/helpers/mod.rs b/program/tests/helpers/mod.rs index ff09b000..7b85f51d 100644 --- a/program/tests/helpers/mod.rs +++ b/program/tests/helpers/mod.rs @@ -4,4 +4,5 @@ pub mod context; pub mod instruction_builders; pub mod lifecycle; +pub mod stake_tracker; pub mod utils; diff --git a/program/tests/helpers/stake_tracker.rs b/program/tests/helpers/stake_tracker.rs new file mode 100644 index 00000000..6885ef51 --- /dev/null +++ b/program/tests/helpers/stake_tracker.rs @@ -0,0 +1,168 @@ +use { + mollusk_svm::Mollusk, + solana_clock::Epoch, + solana_pubkey::Pubkey, + solana_stake_interface::{ + stake_history::{StakeHistory, StakeHistoryEntry}, + state::Delegation, + }, + std::collections::HashMap, +}; + +// This replicates solana-runtime's Banks behavior where stake history is automatically +// updated at epoch boundaries by aggregating all stake delegations. + +/// Tracks stake delegations for automatic stake history management +#[derive(Default, Clone)] +pub struct StakeTracker { + /// Map of stake account pubkey to its delegation info + pub(crate) delegations: HashMap, +} + +#[derive(Clone)] +pub(crate) struct TrackedDelegation { + pub(crate) stake: u64, + pub(crate) activation_epoch: Epoch, + pub(crate) deactivation_epoch: Epoch, + pub(crate) voter_pubkey: Pubkey, +} + +impl StakeTracker { + pub fn new() -> Self { + Self::default() + } + + /// Create a tracker with background cluster stake (like Banks has) + /// This provides the baseline effective stake that enables instant activation/deactivation + pub fn with_background_stake(background_stake: u64) -> Self { + let mut tracker = Self::new(); + + // Add a synthetic background stake that's been active forever (bootstrap stake) + // This mimics Banks' cluster-wide effective stake + tracker.delegations.insert( + Pubkey::new_unique(), // Synthetic background stake pubkey + TrackedDelegation { + stake: background_stake, + activation_epoch: u64::MAX, // Bootstrap = instantly effective + deactivation_epoch: u64::MAX, + voter_pubkey: Pubkey::new_unique(), + }, + ); + + tracker + } + + /// Track a new stake delegation (called after delegate instruction) + pub fn track_delegation( + &mut self, + stake_pubkey: &Pubkey, + stake_amount: u64, + activation_epoch: Epoch, + voter_pubkey: &Pubkey, + ) { + self.delegations.insert( + *stake_pubkey, + TrackedDelegation { + stake: stake_amount, + activation_epoch, + deactivation_epoch: u64::MAX, + voter_pubkey: *voter_pubkey, + }, + ); + } + + /// Mark a stake as deactivating (called after deactivate instruction) + pub fn track_deactivation(&mut self, stake_pubkey: &Pubkey, deactivation_epoch: Epoch) { + if let Some(delegation) = self.delegations.get_mut(stake_pubkey) { + delegation.deactivation_epoch = deactivation_epoch; + } + } + + /// Calculate aggregate stake history for an epoch (replicates Stakes::activate_epoch) + fn calculate_epoch_entry( + &self, + epoch: Epoch, + stake_history: &StakeHistory, + new_rate_activation_epoch: Option, + ) -> StakeHistoryEntry { + self.delegations + .values() + .map(|tracked| { + let delegation = Delegation { + voter_pubkey: tracked.voter_pubkey, + stake: tracked.stake, + activation_epoch: tracked.activation_epoch, + deactivation_epoch: tracked.deactivation_epoch, + ..Delegation::default() + }; + + delegation.stake_activating_and_deactivating( + epoch, + stake_history, + new_rate_activation_epoch, + ) + }) + .fold(StakeHistoryEntry::default(), |acc, status| { + StakeHistoryEntry { + effective: acc.effective + status.effective, + activating: acc.activating + status.activating, + deactivating: acc.deactivating + status.deactivating, + } + }) + } +} + +/// Extension trait that adds stake-aware warping to Mollusk +pub trait MolluskStakeExt { + /// Warp to a slot and automatically update stake history at epoch boundaries + /// + /// This replicates Banks' behavior from solana-runtime: + /// - Bank::warp_from_parent() advances slot + /// - Stakes::activate_epoch() aggregates delegations + /// - Bank::update_stake_history() writes sysvar + fn warp_to_slot_with_stake_tracking( + &mut self, + tracker: &StakeTracker, + target_slot: u64, + new_rate_activation_epoch: Option, + ); +} + +impl MolluskStakeExt for Mollusk { + fn warp_to_slot_with_stake_tracking( + &mut self, + tracker: &StakeTracker, + target_slot: u64, + new_rate_activation_epoch: Option, + ) { + let current_epoch = self.sysvars.clock.epoch; + let current_slot = self.sysvars.clock.slot; + + if target_slot <= current_slot { + panic!( + "Cannot warp backwards: current_slot={}, target_slot={}", + current_slot, target_slot + ); + } + + // Advance the clock (Mollusk's warp_to_slot only updates Clock sysvar) + self.warp_to_slot(target_slot); + + let new_epoch = self.sysvars.clock.epoch; + + // If we crossed epoch boundaries, update stake history for EACH epoch + // StakeHistorySysvar requires contiguous history with no gaps + // This replicates Bank::update_stake_history() + Stakes::activate_epoch() + if new_epoch != current_epoch { + for epoch in current_epoch..new_epoch { + let entry = tracker.calculate_epoch_entry( + epoch, + &self.sysvars.stake_history, + new_rate_activation_epoch, + ); + + self.sysvars.stake_history.add(epoch, entry); + } + } + } +} diff --git a/program/tests/helpers/utils.rs b/program/tests/helpers/utils.rs index 3113d245..c83886cf 100644 --- a/program/tests/helpers/utils.rs +++ b/program/tests/helpers/utils.rs @@ -1,6 +1,7 @@ use { mollusk_svm::Mollusk, solana_account::{Account, AccountSharedData, ReadableAccount, WritableAccount}, + solana_clock::Epoch, solana_instruction::Instruction, solana_pubkey::Pubkey, solana_rent::Rent, @@ -92,3 +93,18 @@ pub fn parse_stake_account( _ => panic!("Expected initialized or staked account"), } } + +/// Increment vote account credits +pub fn increment_vote_account_credits( + vote_account: &mut AccountSharedData, + epoch: Epoch, + credits: u64, +) { + let mut vote_state: VoteStateVersions = bincode::deserialize(vote_account.data()).unwrap(); + + if let VoteStateVersions::V4(ref mut v4) = vote_state { + v4.epoch_credits.push((epoch, credits, 0)); + } + + vote_account.set_data(bincode::serialize(&vote_state).unwrap()); +} diff --git a/program/tests/initialize.rs b/program/tests/initialize.rs index cc62b280..a5d9cc15 100644 --- a/program/tests/initialize.rs +++ b/program/tests/initialize.rs @@ -7,13 +7,17 @@ use { context::StakeTestContext, instruction_builders::{InitializeCheckedConfig, InitializeConfig}, lifecycle::StakeLifecycle, + utils::add_sysvars, }, mollusk_svm::result::Check, - solana_account::{AccountSharedData, ReadableAccount}, + solana_account::{create_account_shared_data_for_test, AccountSharedData, ReadableAccount}, solana_program_error::ProgramError, solana_pubkey::Pubkey, - solana_rent::Rent, - solana_stake_interface::state::{Authorized, Lockup, StakeStateV2}, + solana_rent::{sysvar, Rent}, + solana_stake_interface::{ + instruction as ixn, + state::{Authorized, Lockup, StakeStateV2}, + }, solana_stake_program::id, test_case::test_case, }; @@ -277,3 +281,127 @@ fn test_initialize_incorrect_size_smaller(variant: InitializeVariant) { .execute(), }; } + +/// Ensure that `initialize()` respects the minimum balance requirements +/// - Assert 1: accounts with a balance equal-to the rent exemption initialize OK +/// - Assert 2: accounts with a balance less-than the rent exemption do not initialize +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_minimum_balance(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(), + }; + + // Test exact rent boundary: success at rent_exempt_reserve, failure at rent_exempt_reserve - 1 + for (lamports, expected_result) in [ + (ctx.rent_exempt_reserve, Ok(())), + ( + ctx.rent_exempt_reserve - 1, + Err(ProgramError::InsufficientFunds), + ), + ] { + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + lamports, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of(), + &id(), + ) + .unwrap(); + + let check = match expected_result { + Ok(()) => Check::success(), + Err(e) => Check::err(e), + }; + + match variant { + InitializeVariant::Initialize => ctx + .process_with(InitializeConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + lockup: &lockup, + }) + .checks(&[check]) + .test_missing_signers(false) + .execute(), + InitializeVariant::InitializeChecked => ctx + .process_with(InitializeCheckedConfig { + stake: (&stake, &stake_account), + authorized: &authorized, + }) + .checks(&[check]) + .test_missing_signers(false) + .execute(), + }; + } +} + +#[test_case(InitializeVariant::Initialize; "initialize")] +#[test_case(InitializeVariant::InitializeChecked; "initialize_checked")] +fn test_initialize_rent_change(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 sufficient lamports based on default rent + let stake = Pubkey::new_unique(); + let stake_account = AccountSharedData::new_data_with_space( + ctx.rent_exempt_reserve, + &StakeStateV2::Uninitialized, + StakeStateV2::size_of(), + &id(), + ) + .unwrap(); + + // Build instruction + let instruction = match variant { + InitializeVariant::Initialize => ixn::initialize(&stake, &authorized, &lockup), + InitializeVariant::InitializeChecked => ixn::initialize_checked(&stake, &authorized), + }; + + // Create modified rent with increased lamports_per_byte_year + // This simulates rent increasing between account creation and initialization + let default_rent = Rent::default(); + let modified_rent = Rent { + lamports_per_byte_year: default_rent.lamports_per_byte_year + 1, + ..default_rent + }; + + // Create rent sysvar account with modified rent + let rent_account = create_account_shared_data_for_test(&modified_rent); + + // Include the modified rent account in the accounts list + // add_sysvars will use this instead of the default when it sees rent::id() in instruction + let accounts = vec![(stake, stake_account), (sysvar::id(), rent_account)]; + + // Test that initialization fails with insufficient funds due to rent increase + ctx.mollusk.process_and_validate_instruction( + &instruction, + &add_sysvars(&ctx.mollusk, &instruction, accounts), + &[Check::err(ProgramError::InsufficientFunds)], + ); +} diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs index 6c45ef00..7ce8b298 100644 --- a/program/tests/program_test.rs +++ b/program/tests/program_test.rs @@ -17,7 +17,7 @@ use { instruction::{self as ixn, LockupArgs}, program::id, stake_history::StakeHistory, - state::{Authorized, Delegation, Lockup, Meta, Stake, StakeAuthorize, StakeStateV2}, + state::{Authorized, Lockup, Meta, Stake, StakeAuthorize, StakeStateV2}, }, solana_system_interface::instruction as system_instruction, solana_transaction::{Signers, Transaction, TransactionError}, @@ -630,148 +630,6 @@ async fn program_test_authorize() { } } -#[tokio::test] -async fn program_test_stake_delegate() { - let mut context = program_test().start_with_context().await; - let accounts = Accounts::default(); - accounts.initialize(&mut context).await; - - let vote_account2 = Keypair::new(); - create_vote( - &mut context, - &Keypair::new(), - &Pubkey::new_unique(), - &Pubkey::new_unique(), - &vote_account2, - ) - .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 vote_state_credits = 100; - context.increment_vote_account_credits(&accounts.vote_account.pubkey(), vote_state_credits); - let minimum_delegation = get_minimum_delegation(&mut context).await; - - let stake = - create_independent_stake_account(&mut context, &authorized, minimum_delegation).await; - let instruction = ixn::delegate_stake(&stake, &staker, &accounts.vote_account.pubkey()); - - process_instruction_test_missing_signers(&mut context, &instruction, &vec![&staker_keypair]) - .await; - - // verify that delegate() looks right - 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(), - Stake { - delegation: Delegation { - voter_pubkey: accounts.vote_account.pubkey(), - stake: minimum_delegation, - activation_epoch: clock.epoch, - deactivation_epoch: u64::MAX, - ..Delegation::default() - }, - credits_observed: vote_state_credits, - } - ); - - // verify that delegate fails as stake is active and not deactivating - advance_epoch(&mut context).await; - let instruction = ixn::delegate_stake(&stake, &staker, &accounts.vote_account.pubkey()); - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, StakeError::TooSoonToRedelegate.into()); - - // deactivate - let instruction = ixn::deactivate_stake(&stake, &staker); - process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap(); - - // verify that delegate to a different vote account fails during deactivation - let instruction = ixn::delegate_stake(&stake, &staker, &vote_account2.pubkey()); - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, StakeError::TooSoonToRedelegate.into()); - - // verify that delegate succeeds to same vote account when stake is deactivating - refresh_blockhash(&mut context).await; - let instruction = ixn::delegate_stake(&stake, &staker, &accounts.vote_account.pubkey()); - process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap(); - - // verify that deactivation has been cleared - let (_, stake_data, _) = get_stake_account(&mut context.banks_client, &stake).await; - assert_eq!(stake_data.unwrap().delegation.deactivation_epoch, u64::MAX); - - // verify that delegate to a different vote account fails if stake is still - // active - let instruction = ixn::delegate_stake(&stake, &staker, &vote_account2.pubkey()); - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, StakeError::TooSoonToRedelegate.into()); - - // delegate still fails after stake is fully activated; redelegate is not - // supported - advance_epoch(&mut context).await; - let instruction = ixn::delegate_stake(&stake, &staker, &vote_account2.pubkey()); - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, StakeError::TooSoonToRedelegate.into()); - - // delegate to spoofed vote account fails (not owned by vote program) - let mut fake_vote_account = - get_account(&mut context.banks_client, &accounts.vote_account.pubkey()).await; - fake_vote_account.owner = Pubkey::new_unique(); - let fake_vote_address = Pubkey::new_unique(); - context.set_account(&fake_vote_address, &fake_vote_account.into()); - - let stake = - create_independent_stake_account(&mut context, &authorized, minimum_delegation).await; - let instruction = ixn::delegate_stake(&stake, &staker, &fake_vote_address); - - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::IncorrectProgramId); - - // delegate stake program-owned non-stake account fails - let rewards_pool_address = Pubkey::new_unique(); - let rewards_pool = SolanaAccount { - lamports: get_stake_account_rent(&mut context.banks_client).await, - data: bincode::serialize(&StakeStateV2::RewardsPool) - .unwrap() - .to_vec(), - owner: id(), - executable: false, - rent_epoch: u64::MAX, - }; - context.set_account(&rewards_pool_address, &rewards_pool.into()); - - let instruction = ixn::delegate_stake( - &rewards_pool_address, - &staker, - &accounts.vote_account.pubkey(), - ); - - let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) - .await - .unwrap_err(); - assert_eq!(e, ProgramError::InvalidAccountData); -} - #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum StakeLifecycle { Uninitialized = 0, diff --git a/program/tests/stake_instruction.rs b/program/tests/stake_instruction.rs index 8aa9fd5a..b74f3998 100644 --- a/program/tests/stake_instruction.rs +++ b/program/tests/stake_instruction.rs @@ -1118,110 +1118,6 @@ fn test_stake_checked_instructions() { ); } -#[test] -fn test_stake_initialize() { - let mollusk = mollusk_bpf(); - - let rent = Rent::default(); - let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); - let stake_lamports = rent_exempt_reserve; - let stake_address = solana_pubkey::new_rand(); - let stake_account = AccountSharedData::new(stake_lamports, StakeStateV2::size_of(), &id()); - let custodian_address = solana_pubkey::new_rand(); - let lockup = Lockup { - epoch: 1, - unix_timestamp: 0, - custodian: custodian_address, - }; - let instruction_data = serialize(&StakeInstruction::Initialize( - Authorized::auto(&stake_address), - lockup, - )) - .unwrap(); - let mut transaction_accounts = vec![ - (stake_address, stake_account.clone()), - (rent::id(), create_account_shared_data_for_test(&rent)), - ]; - let instruction_accounts = vec![ - AccountMeta { - pubkey: stake_address, - is_signer: false, - is_writable: true, - }, - AccountMeta { - pubkey: rent::id(), - is_signer: false, - is_writable: false, - }, - ]; - - // should pass - let accounts = process_instruction( - &mollusk, - &instruction_data, - transaction_accounts.clone(), - instruction_accounts.clone(), - Ok(()), - ); - // check that we see what we expect - assert_eq!( - from(&accounts[0]).unwrap(), - StakeStateV2::Initialized(Meta { - authorized: Authorized::auto(&stake_address), - rent_exempt_reserve, - lockup, - }), - ); - - // 2nd time fails, can't move it from anything other than uninit->init - transaction_accounts[0] = (stake_address, accounts[0].clone()); - process_instruction( - &mollusk, - &instruction_data, - transaction_accounts.clone(), - instruction_accounts.clone(), - Err(ProgramError::InvalidAccountData), - ); - transaction_accounts[0] = (stake_address, stake_account); - - // not enough balance for rent - transaction_accounts[1] = ( - rent::id(), - create_account_shared_data_for_test(&Rent { - lamports_per_byte_year: rent.lamports_per_byte_year + 1, - ..rent - }), - ); - process_instruction( - &mollusk, - &instruction_data, - transaction_accounts.clone(), - instruction_accounts.clone(), - Err(ProgramError::InsufficientFunds), - ); - - // incorrect account sizes - let stake_account = AccountSharedData::new(stake_lamports, StakeStateV2::size_of() + 1, &id()); - transaction_accounts[0] = (stake_address, stake_account); - process_instruction( - &mollusk, - &instruction_data, - transaction_accounts.clone(), - instruction_accounts.clone(), - Err(ProgramError::InvalidAccountData), - ); - - let stake_account = AccountSharedData::new(stake_lamports, StakeStateV2::size_of() - 1, &id()); - transaction_accounts[0] = (stake_address, stake_account); - process_instruction( - &mollusk, - &instruction_data, - transaction_accounts, - instruction_accounts, - Err(ProgramError::InvalidAccountData), - ); -} - #[test] fn test_authorize() { let mollusk = mollusk_bpf(); @@ -3485,54 +3381,6 @@ fn test_set_lockup() { ); } -/// Ensure that `initialize()` respects the minimum balance requirements -/// - Assert 1: accounts with a balance equal-to the rent exemption initialize OK -/// - Assert 2: accounts with a balance less-than the rent exemption do not initialize -#[test] -fn test_initialize_minimum_balance() { - let mollusk = mollusk_bpf(); - - let rent = Rent::default(); - let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); - let stake_address = solana_pubkey::new_rand(); - let instruction_data = serialize(&StakeInstruction::Initialize( - Authorized::auto(&stake_address), - Lockup::default(), - )) - .unwrap(); - let instruction_accounts = vec![ - AccountMeta { - pubkey: stake_address, - is_signer: false, - is_writable: true, - }, - AccountMeta { - pubkey: rent::id(), - is_signer: false, - is_writable: false, - }, - ]; - for (lamports, expected_result) in [ - (rent_exempt_reserve, Ok(())), - ( - rent_exempt_reserve - 1, - Err(ProgramError::InsufficientFunds), - ), - ] { - let stake_account = AccountSharedData::new(lamports, StakeStateV2::size_of(), &id()); - process_instruction( - &mollusk, - &instruction_data, - vec![ - (stake_address, stake_account), - (rent::id(), create_account_shared_data_for_test(&rent)), - ], - instruction_accounts.clone(), - expected_result, - ); - } -} - /// Ensure that `delegate()` respects the minimum delegation requirements /// - Assert 1: delegating an amount equal-to the minimum succeeds /// - Assert 2: delegating an amount less-than the minimum fails diff --git a/program/tests/stake_tracker_equivalence.rs b/program/tests/stake_tracker_equivalence.rs new file mode 100644 index 00000000..e3f5ed33 --- /dev/null +++ b/program/tests/stake_tracker_equivalence.rs @@ -0,0 +1,1329 @@ +#![allow(clippy::arithmetic_side_effects)] + +//! Equivalence tests proving StakeTracker (Mollusk) matches BanksClient (solana-program-test) +//! +//! These tests run identical stake operations through both implementations and compare results +//! to ensure 1:1 behavioral equivalence in stake history tracking. +use { + mollusk_svm::Mollusk, + solana_account::{AccountSharedData, ReadableAccount, WritableAccount}, + solana_clock::Clock, + solana_keypair::Keypair, + solana_program_test::{ProgramTest, ProgramTestContext}, + solana_pubkey::Pubkey, + solana_signer::Signer, + solana_stake_interface::{ + instruction as ixn, + stake_history::StakeHistory, + state::{Authorized, Lockup, StakeStateV2}, + }, + solana_stake_program::id, + solana_system_interface::instruction as system_instruction, + solana_transaction::Transaction, + test_case::test_case, +}; + +mod helpers; +use helpers::{ + stake_tracker::{MolluskStakeExt, StakeTracker}, + utils::{add_sysvars, create_vote_account, STAKE_RENT_EXEMPTION}, +}; + +// Constants for testing +const MINIMUM_DELEGATION: u64 = 1; + +/// Dual context holding both BanksClient and Mollusk paths +struct DualContext { + // BanksClient path + program_test_ctx: ProgramTestContext, + + // Mollusk path + mollusk: Mollusk, + tracker: StakeTracker, + + // Shared test data + vote_account: Pubkey, + vote_account_data: AccountSharedData, + background_stake: u64, +} + +impl DualContext { + /// Create both contexts with matching initial state + async fn new() -> Self { + // Initialize program test (BanksClient path) + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(true); + program_test.add_upgradeable_program_to_genesis("solana_stake_program", &id()); + let mut program_test_ctx = program_test.start_with_context().await; + + // Warp to first normal slot on Banks + let slot = program_test_ctx + .genesis_config() + .epoch_schedule + .first_normal_slot + + 1; + program_test_ctx.warp_to_slot(slot).unwrap(); + + // Initialize Mollusk and sync to the same epoch as Banks + let mut mollusk = Mollusk::new(&id(), "solana_stake_program"); + // Banks and Mollusk have different epoch schedules, so we need to get to same epoch + // Get the epoch Banks is at after warping + let banks_clock = program_test_ctx + .banks_client + .get_sysvar::() + .await + .unwrap(); + let banks_epoch = banks_clock.epoch; + + // Warp Mollusk to the same epoch by calculating the corresponding slot + let mollusk_slot_for_epoch = mollusk + .sysvars + .epoch_schedule + .get_first_slot_in_epoch(banks_epoch); + mollusk.warp_to_slot(mollusk_slot_for_epoch); + + // Extract BanksClient's actual background stake from its genesis stake history + // This ensures Mollusk uses the EXACT same background stake for identical history + let banks_stake_history = program_test_ctx + .banks_client + .get_sysvar::() + .await + .unwrap(); + + let epoch_0_entry = banks_stake_history.get(0).cloned().unwrap_or_default(); + let background_stake = epoch_0_entry.effective; + + let tracker = StakeTracker::with_background_stake(background_stake); + + // Initialize Mollusk's stake history to match BanksClient + // Banks may not have history for all intermediate epochs when warped directly, + // so we'll generate them using the tracker (which only has background stake initially) + for epoch in 0..banks_epoch { + if let Some(entry) = banks_stake_history.get(epoch).cloned() { + // Use Banks' actual entry if it exists + mollusk.sysvars.stake_history.add(epoch, entry); + } else { + // Generate entry with just background stake for missing epochs + // This matches what would happen if we had advanced through these epochs naturally + mollusk + .sysvars + .stake_history + .add(epoch, epoch_0_entry.clone()); + } + } + + // Create shared vote account + let vote_account = Pubkey::new_unique(); + let vote_account_data = create_vote_account(); + + // Add vote account to BanksClient (clone to keep original) + program_test_ctx.set_account(&vote_account, &vote_account_data.clone()); + + Self { + program_test_ctx, + mollusk, + tracker, + vote_account, + vote_account_data, + background_stake, + } + } + + /// Create a blank stake account on both paths + async fn create_blank_stake_account(&mut self) -> Pubkey { + let stake_keypair = Keypair::new(); + let stake = stake_keypair.pubkey(); + + // BanksClient path + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::create_account( + &self.program_test_ctx.payer.pubkey(), + &stake, + STAKE_RENT_EXEMPTION, + StakeStateV2::size_of() as u64, + &id(), + )], + Some(&self.program_test_ctx.payer.pubkey()), + &[&self.program_test_ctx.payer, &stake_keypair], + self.program_test_ctx.last_blockhash, + ); + self.program_test_ctx + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // Mollusk path - just track that we'll add it when needed + // (Mollusk accounts are passed per-instruction, not stored globally) + + stake + } + + /// Initialize a stake account on both paths + async fn initialize_stake_account( + &mut self, + stake: &Pubkey, + authorized: &Authorized, + lockup: &Lockup, + ) -> AccountSharedData { + let instruction = ixn::initialize(stake, authorized, lockup); + + // BanksClient path + let transaction = Transaction::new_signed_with_payer( + &[instruction.clone()], + Some(&self.program_test_ctx.payer.pubkey()), + &[&self.program_test_ctx.payer], + self.program_test_ctx.last_blockhash, + ); + self.program_test_ctx + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // Get account from BanksClient + let banks_account = self + .program_test_ctx + .banks_client + .get_account(*stake) + .await + .unwrap() + .unwrap(); + + // Mollusk path - create matching account + let mut mollusk_account = + AccountSharedData::new(STAKE_RENT_EXEMPTION, StakeStateV2::size_of(), &id()); + + let accounts = vec![(*stake, mollusk_account.clone())]; + let accounts_with_sysvars = add_sysvars(&self.mollusk, &instruction, accounts); + let result = self + .mollusk + .process_instruction(&instruction, &accounts_with_sysvars); + assert!(result.program_result.is_ok()); + mollusk_account = result.resulting_accounts[0].1.clone().into(); + + // Verify accounts match + assert_eq!(banks_account.data, mollusk_account.data()); + assert_eq!(banks_account.lamports, mollusk_account.lamports()); + + mollusk_account + } + + /// Delegate stake on both paths to a specific vote account + async fn delegate_stake_to( + &mut self, + stake: &Pubkey, + stake_account: &mut AccountSharedData, + staker_keypair: &Keypair, + vote_account: &Pubkey, + vote_account_data: &AccountSharedData, + ) { + let instruction = ixn::delegate_stake(stake, &staker_keypair.pubkey(), vote_account); + + // BanksClient path + let transaction = Transaction::new_signed_with_payer( + &[instruction.clone()], + Some(&self.program_test_ctx.payer.pubkey()), + &[&self.program_test_ctx.payer, staker_keypair], + self.program_test_ctx.last_blockhash, + ); + self.program_test_ctx + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // Mollusk path + let accounts = vec![ + (*stake, stake_account.clone()), + (*vote_account, vote_account_data.clone()), + ]; + let accounts_with_sysvars = add_sysvars(&self.mollusk, &instruction, accounts); + let result = self + .mollusk + .process_instruction(&instruction, &accounts_with_sysvars); + assert!(result.program_result.is_ok()); + *stake_account = result.resulting_accounts[0].1.clone().into(); + + // Track delegation in Mollusk tracker + let stake_state: StakeStateV2 = bincode::deserialize(stake_account.data()).unwrap(); + if let StakeStateV2::Stake(_, stake_data, _) = stake_state { + self.tracker.track_delegation( + stake, + stake_data.delegation.stake, + stake_data.delegation.activation_epoch, + vote_account, + ); + } + } + + /// Delegate stake on both paths (uses default vote account) + async fn delegate_stake( + &mut self, + stake: &Pubkey, + stake_account: &mut AccountSharedData, + staker_keypair: &Keypair, + ) { + let vote_account = self.vote_account; + let vote_account_data = self.vote_account_data.clone(); + self.delegate_stake_to( + stake, + stake_account, + staker_keypair, + &vote_account, + &vote_account_data, + ) + .await; + } + + /// Deactivate stake on both paths + async fn deactivate_stake( + &mut self, + stake: &Pubkey, + stake_account: &mut AccountSharedData, + staker_keypair: &Keypair, + ) { + let instruction = ixn::deactivate_stake(stake, &staker_keypair.pubkey()); + + // BanksClient path + let transaction = Transaction::new_signed_with_payer( + &[instruction.clone()], + Some(&self.program_test_ctx.payer.pubkey()), + &[&self.program_test_ctx.payer, staker_keypair], + self.program_test_ctx.last_blockhash, + ); + self.program_test_ctx + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // Mollusk path + let accounts = vec![(*stake, stake_account.clone())]; + let accounts_with_sysvars = add_sysvars(&self.mollusk, &instruction, accounts); + let result = self + .mollusk + .process_instruction(&instruction, &accounts_with_sysvars); + assert!(result.program_result.is_ok()); + *stake_account = result.resulting_accounts[0].1.clone().into(); + + // Track deactivation + let deactivation_epoch = self.mollusk.sysvars.clock.epoch; + self.tracker.track_deactivation(stake, deactivation_epoch); + } + + /// Advance epoch on both paths with default new_rate_activation_epoch (Some(0)) + async fn advance_epoch(&mut self) { + self.advance_epoch_with_rate(Some(0)).await; + } + + /// Advance epoch on both paths with custom new_rate_activation_epoch + /// Pass None to use old warmup rate behavior + async fn advance_epoch_with_rate(&mut self, new_rate_activation_epoch: Option) { + // Refresh blockhash for BanksClient by advancing slot slightly first + let current_slot = self + .program_test_ctx + .banks_client + .get_root_slot() + .await + .unwrap(); + self.program_test_ctx + .warp_to_slot(current_slot + 1) + .unwrap(); + self.program_test_ctx.last_blockhash = self + .program_test_ctx + .banks_client + .get_latest_blockhash() + .await + .unwrap(); + + // BanksClient path - advance epoch + let root_slot = self + .program_test_ctx + .banks_client + .get_root_slot() + .await + .unwrap(); + let slots_per_epoch = self + .program_test_ctx + .genesis_config() + .epoch_schedule + .slots_per_epoch; + self.program_test_ctx + .warp_to_slot(root_slot + slots_per_epoch) + .unwrap(); + + // Mollusk path - advance epoch with stake tracking + let current_slot = self.mollusk.sysvars.clock.slot; + let mollusk_slots_per_epoch = self.mollusk.sysvars.epoch_schedule.slots_per_epoch; + let target_slot = current_slot + mollusk_slots_per_epoch; + self.mollusk.warp_to_slot_with_stake_tracking( + &self.tracker, + target_slot, + new_rate_activation_epoch, + ); + } + + /// Get stake history from BanksClient + async fn get_banks_stake_history(&mut self) -> StakeHistory { + self.program_test_ctx + .banks_client + .get_sysvar::() + .await + .unwrap() + } + + /// Get stake history from Mollusk + fn get_mollusk_stake_history(&self) -> &StakeHistory { + &self.mollusk.sysvars.stake_history + } + + /// Get effective stake from BanksClient + async fn get_banks_effective_stake(&mut self, stake: &Pubkey) -> u64 { + let clock = self + .program_test_ctx + .banks_client + .get_sysvar::() + .await + .unwrap(); + let stake_history = self.get_banks_stake_history().await; + let account = self + .program_test_ctx + .banks_client + .get_account(*stake) + .await + .unwrap() + .unwrap(); + + match bincode::deserialize::(&account.data).unwrap() { + StakeStateV2::Stake(_, stake_data, _) => { + stake_data + .delegation + .stake_activating_and_deactivating(clock.epoch, &stake_history, Some(0)) + .effective + } + _ => 0, + } + } + + /// Get effective stake from Mollusk + fn get_mollusk_effective_stake(&self, stake_account: &AccountSharedData) -> u64 { + let clock = &self.mollusk.sysvars.clock; + let stake_history = &self.mollusk.sysvars.stake_history; + + match bincode::deserialize::(stake_account.data()).unwrap() { + StakeStateV2::Stake(_, stake_data, _) => { + stake_data + .delegation + .stake_activating_and_deactivating(clock.epoch, stake_history, Some(0)) + .effective + } + _ => 0, + } + } + + /// Compare stake history entries between both implementations + /// Verifies all components: effective, activating, and deactivating + async fn compare_stake_history(&mut self, epoch: u64) { + let banks_history = self + .program_test_ctx + .banks_client + .get_sysvar::() + .await + .unwrap(); + let mollusk_history = &self.mollusk.sysvars.stake_history; + + let banks_entry = banks_history.get(epoch); + let mollusk_entry = mollusk_history.get(epoch); + + assert_eq!( + banks_entry, mollusk_entry, + "Stake history mismatch at epoch {}: BanksClient={:?}, Mollusk={:?}", + epoch, banks_entry, mollusk_entry + ); + } + + /// Verify background stake is preserved in stake history across implementations + async fn verify_background_stake_preservation(&mut self, epoch: u64, expected_background: u64) { + let banks_history = self + .program_test_ctx + .banks_client + .get_sysvar::() + .await + .unwrap(); + let mollusk_history = &self.mollusk.sysvars.stake_history; + + if let Some(banks_entry) = banks_history.get(epoch) { + assert!( + banks_entry.effective >= expected_background, + "Epoch {}: Banks effective stake {} should include background {}", + epoch, + banks_entry.effective, + expected_background + ); + } + + if let Some(mollusk_entry) = mollusk_history.get(epoch) { + assert!( + mollusk_entry.effective >= expected_background, + "Epoch {}: Mollusk effective stake {} should include background {}", + epoch, + mollusk_entry.effective, + expected_background + ); + } + } + + /// Compare account state between both paths + async fn compare_account_state(&mut self, stake: &Pubkey, mollusk_account: &AccountSharedData) { + let banks_account = self + .program_test_ctx + .banks_client + .get_account(*stake) + .await + .unwrap() + .unwrap(); + + assert_eq!( + banks_account.lamports, + mollusk_account.lamports(), + "Lamports mismatch" + ); + + let banks_state: StakeStateV2 = bincode::deserialize(&banks_account.data).unwrap(); + let mollusk_state: StakeStateV2 = bincode::deserialize(mollusk_account.data()).unwrap(); + + match (banks_state, mollusk_state) { + (StakeStateV2::Stake(b_meta, b_stake, _), StakeStateV2::Stake(m_meta, m_stake, _)) => { + assert_eq!(b_meta.authorized, m_meta.authorized); + assert_eq!(b_meta.lockup, m_meta.lockup); + assert_eq!(b_stake.delegation.stake, m_stake.delegation.stake); + assert_eq!( + b_stake.delegation.activation_epoch, + m_stake.delegation.activation_epoch + ); + assert_eq!( + b_stake.delegation.deactivation_epoch, + m_stake.delegation.deactivation_epoch + ); + } + (StakeStateV2::Initialized(b_meta), StakeStateV2::Initialized(m_meta)) => { + assert_eq!(b_meta.authorized, m_meta.authorized); + assert_eq!(b_meta.lockup, m_meta.lockup); + } + _ => { + panic!( + "State type mismatch: banks={:?}, mollusk={:?}", + banks_state, mollusk_state + ); + } + } + } + + /// Advance one epoch and compare stake account state between implementations + async fn advance_and_compare_stake( + &mut self, + stake: &Pubkey, + stake_account: &AccountSharedData, + ) { + self.advance_and_compare_stake_with_rate(stake, stake_account, Some(0)) + .await; + } + + /// Advance one epoch with custom warmup rate and compare stake account state + async fn advance_and_compare_stake_with_rate( + &mut self, + stake: &Pubkey, + stake_account: &AccountSharedData, + new_rate_activation_epoch: Option, + ) { + self.advance_epoch_with_rate(new_rate_activation_epoch) + .await; + let epoch = self.mollusk.sysvars.clock.epoch; + self.compare_stake_history(epoch - 1).await; + + let banks_effective = self.get_banks_effective_stake(stake).await; + let mollusk_effective = self.get_mollusk_effective_stake(stake_account); + assert_eq!( + banks_effective, mollusk_effective, + "Epoch {}: effective stake mismatch", + epoch + ); + self.compare_account_state(stake, stake_account).await; + } + + /// Advance epochs until stake is fully activated (effective == delegated amount) + /// Returns number of epochs advanced. Panics if not activated within max_epochs. + async fn advance_until_fully_activated( + &mut self, + stake: &Pubkey, + stake_account: &AccountSharedData, + max_epochs: u64, + ) -> u64 { + let stake_state: StakeStateV2 = bincode::deserialize(stake_account.data()).unwrap(); + let target_amount = match stake_state { + StakeStateV2::Stake(_, stake_data, _) => stake_data.delegation.stake, + _ => panic!("Stake account not in delegated state"), + }; + + let mut epochs_advanced = 0; + loop { + self.advance_and_compare_stake(stake, stake_account).await; + epochs_advanced += 1; + + let banks_effective = self.get_banks_effective_stake(stake).await; + if banks_effective == target_amount { + return epochs_advanced; + } + + assert!( + epochs_advanced < max_epochs, + "Stake did not fully activate within {} epochs", + max_epochs + ); + } + } + + /// Advance epochs until stake is fully deactivated (effective == 0) + /// Returns number of epochs advanced. Panics if not deactivated within max_epochs. + async fn advance_until_fully_deactivated( + &mut self, + stake: &Pubkey, + stake_account: &AccountSharedData, + max_epochs: u64, + ) -> u64 { + let mut epochs_advanced = 0; + loop { + self.advance_and_compare_stake(stake, stake_account).await; + epochs_advanced += 1; + + let banks_effective = self.get_banks_effective_stake(stake).await; + if banks_effective == 0 { + return epochs_advanced; + } + + assert!( + epochs_advanced < max_epochs, + "Stake did not fully deactivate within {} epochs", + max_epochs + ); + } + } + + /// Advance one epoch and compare multiple stake accounts between implementations + async fn advance_and_compare_stakes(&mut self, stakes: &[(&Pubkey, &AccountSharedData)]) { + self.advance_and_compare_stakes_with_rate(stakes, Some(0)) + .await; + } + + /// Advance one epoch with custom warmup rate and compare multiple stake accounts + async fn advance_and_compare_stakes_with_rate( + &mut self, + stakes: &[(&Pubkey, &AccountSharedData)], + new_rate_activation_epoch: Option, + ) { + self.advance_epoch_with_rate(new_rate_activation_epoch) + .await; + let epoch = self.mollusk.sysvars.clock.epoch; + self.compare_stake_history(epoch - 1).await; + + for (stake, stake_account) in stakes { + let banks_effective = self.get_banks_effective_stake(stake).await; + let mollusk_effective = self.get_mollusk_effective_stake(stake_account); + assert_eq!( + banks_effective, mollusk_effective, + "Epoch {}: stake {} mismatch", + epoch, stake + ); + self.compare_account_state(stake, stake_account).await; + } + } + + /// Fund a stake account on BanksClient side + async fn fund_stake_account(&mut self, stake: &Pubkey, amount: u64) { + let fund_ix = + system_instruction::transfer(&self.program_test_ctx.payer.pubkey(), stake, amount); + let transaction = Transaction::new_signed_with_payer( + &[fund_ix], + Some(&self.program_test_ctx.payer.pubkey()), + &[&self.program_test_ctx.payer], + self.program_test_ctx.last_blockhash, + ); + self.program_test_ctx + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + } + + /// Create, initialize, and fund a stake account + /// Returns (stake pubkey, stake account, staker keypair) + async fn create_and_fund_stake( + &mut self, + staked_amount: u64, + lockup: &Lockup, + ) -> (Pubkey, AccountSharedData, Keypair) { + let stake = self.create_blank_stake_account().await; + let staker = Keypair::new(); + let authorized = Authorized { + staker: staker.pubkey(), + withdrawer: Pubkey::new_unique(), + }; + + let mut stake_account = self + .initialize_stake_account(&stake, &authorized, lockup) + .await; + + stake_account.set_lamports(stake_account.lamports() + staked_amount); + self.fund_stake_account(&stake, staked_amount).await; + + (stake, stake_account, staker) + } +} + +// Test single delegation activation over various epoch counts +// Including large numbers representing years of operation (assuming ~182 epochs/year @ 2 days/epoch) +// Also tests stake history depth limit (512 epochs) to ensure old entries drop correctly +#[test_case(5 ; "five_epochs")] +#[test_case(10 ; "ten_epochs")] +#[test_case(20 ; "twenty_epochs")] +#[test_case(50 ; "fifty_epochs")] +#[test_case(100 ; "hundred_epochs")] +#[test_case(182 ; "one_year")] +#[test_case(500 ; "five_hundred_epochs")] +#[test_case(512 ; "stake_history_depth_boundary")] +#[test_case(515 ; "beyond_stake_history_depth")] +#[test_case(1800 ; "ten_years")] +#[tokio::test] +async fn test_single_delegation_activation(num_epochs: u64) { + let mut ctx = DualContext::new().await; + + // Create, initialize, and fund stake account + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + // Delegate stake + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + for i in 0..num_epochs { + ctx.advance_and_compare_stake(&stake, &stake_account).await; + + let epoch = ctx.mollusk.sysvars.clock.epoch; + if num_epochs >= 100 && (i % 100 == 0 || i == num_epochs - 1) { + ctx.verify_background_stake_preservation(epoch - 1, ctx.background_stake) + .await; + } + } +} + +// Test deactivation at different points in lifecycle: immediately, during warmup, or after activation +#[test_case(0 ; "immediate_same_epoch")] +#[test_case(1 ; "early_warmup")] +#[test_case(2 ; "mid_warmup")] +#[test_case(3 ; "late_warmup")] +#[test_case(5 ; "after_activation")] +#[tokio::test] +async fn test_deactivation_timing(epochs_before_deactivate: u64) { + let mut ctx = DualContext::new().await; + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + for _ in 0..epochs_before_deactivate { + ctx.advance_and_compare_stake(&stake, &stake_account).await; + } + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_deactivated(&stake, &stake_account, 20) + .await; +} + +// Test various stake amounts: minimum, small, large +#[test_case(MINIMUM_DELEGATION ; "minimum_delegation")] +#[test_case(MINIMUM_DELEGATION * 100 ; "small_amount")] +#[test_case(250_000 * 1_000_000_000 ; "large_amount")] +#[tokio::test] +async fn test_stake_amounts_activation(staked_amount: u64) { + let mut ctx = DualContext::new().await; + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(staked_amount, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_activated(&stake, &stake_account, 50) + .await; +} + +// Test deactivation with various stake amounts to verify warmup rates match during cooldown +#[test_case(MINIMUM_DELEGATION ; "minimum_delegation")] +#[test_case(MINIMUM_DELEGATION * 100 ; "small_amount")] +#[test_case(250_000 * 1_000_000_000 ; "large_amount")] +#[tokio::test] +async fn test_stake_amounts_deactivation(staked_amount: u64) { + let mut ctx = DualContext::new().await; + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(staked_amount, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_activated(&stake, &stake_account, 50) + .await; + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_deactivated(&stake, &stake_account, 50) + .await; +} + +// Test multiple stakes delegating simultaneously, optionally to multiple vote accounts +#[test_case(5, 1 ; "five_stakes_one_vote")] +#[test_case(10, 1 ; "ten_stakes_one_vote")] +#[test_case(20, 1 ; "twenty_stakes_one_vote")] +#[test_case(4, 2 ; "four_stakes_two_votes")] +#[tokio::test] +async fn test_multiple_simultaneous_delegations(num_stakes: usize, num_vote_accounts: usize) { + let mut ctx = DualContext::new().await; + + // Create additional vote accounts if needed + let mut vote_accounts = vec![(ctx.vote_account, ctx.vote_account_data.clone())]; + for _ in 1..num_vote_accounts { + let vote_account = Pubkey::new_unique(); + let vote_account_data = create_vote_account(); + ctx.program_test_ctx + .set_account(&vote_account, &vote_account_data.clone()); + vote_accounts.push((vote_account, vote_account_data)); + } + + let mut stakes = Vec::new(); + let mut stake_accounts = Vec::new(); + let mut stakers = Vec::new(); + + for i in 0..num_stakes { + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + // Delegate to vote account (alternate if multiple) + let (vote_account, vote_account_data) = &vote_accounts[i % num_vote_accounts]; + ctx.delegate_stake_to( + &stake, + &mut stake_account, + &staker, + vote_account, + vote_account_data, + ) + .await; + + stakes.push(stake); + stake_accounts.push(stake_account); + stakers.push(staker); + } + + for _ in 0..10 { + let stake_refs: Vec<_> = stakes.iter().zip(stake_accounts.iter()).collect(); + ctx.advance_and_compare_stakes(&stake_refs).await; + } +} + +#[tokio::test] +async fn test_concurrent_activation_and_deactivation() { + let mut ctx = DualContext::new().await; + + let (stake_a, mut stake_account_a, staker_a) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + let (stake_b, mut stake_account_b, staker_b) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake_b, &mut stake_account_b, &staker_b) + .await; + ctx.advance_epoch().await; + + ctx.deactivate_stake(&stake_b, &mut stake_account_b, &staker_b) + .await; + ctx.delegate_stake(&stake_a, &mut stake_account_a, &staker_a) + .await; + + let epoch = ctx.mollusk.sysvars.clock.epoch; + + for _ in 0..5 { + ctx.advance_epoch().await; + let current_epoch = ctx.mollusk.sysvars.clock.epoch; + ctx.compare_stake_history(current_epoch - 1).await; + + let banks_effective_a = ctx.get_banks_effective_stake(&stake_a).await; + let mollusk_effective_a = ctx.get_mollusk_effective_stake(&stake_account_a); + assert_eq!(banks_effective_a, mollusk_effective_a); + ctx.compare_account_state(&stake_a, &stake_account_a).await; + + let banks_effective_b = ctx.get_banks_effective_stake(&stake_b).await; + let mollusk_effective_b = ctx.get_mollusk_effective_stake(&stake_account_b); + assert_eq!(banks_effective_b, mollusk_effective_b); + ctx.compare_account_state(&stake_b, &stake_account_b).await; + } + + let banks_history = ctx.get_banks_stake_history().await; + let mollusk_history = ctx.get_mollusk_stake_history(); + + let banks_entry = banks_history.get(epoch).unwrap(); + let mollusk_entry = mollusk_history.get(epoch).unwrap(); + + assert_eq!(banks_entry.activating, mollusk_entry.activating); + assert_eq!(banks_entry.deactivating, mollusk_entry.deactivating); +} + +#[tokio::test] +async fn test_reactivation_after_deactivation() { + let mut ctx = DualContext::new().await; + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + for _ in 0..2 { + ctx.advance_and_compare_stake(&stake, &stake_account).await; + } + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_deactivated(&stake, &stake_account, 20) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_activated(&stake, &stake_account, 20) + .await; +} + +#[tokio::test] +async fn test_staggered_delegations_over_epochs() { + let mut ctx = DualContext::new().await; + + let num_stakes = 5; + let mut stakes = Vec::new(); + let mut stake_accounts = Vec::new(); + let mut stakers = Vec::new(); + + // Create all stakes + for _ in 0..num_stakes { + let stake = ctx.create_blank_stake_account().await; + let staker = Keypair::new(); + let authorized = Authorized { + staker: staker.pubkey(), + withdrawer: Pubkey::new_unique(), + }; + + let stake_account = ctx + .initialize_stake_account(&stake, &authorized, &Lockup::default()) + .await; + + stakes.push(stake); + stake_accounts.push(stake_account); + stakers.push(staker); + } + + // Stagger delegations across epochs (0, 5, 10, 15, 20) + for (idx, i) in [0, 5, 10, 15, 20].iter().enumerate() { + // Advance to target epoch + while ctx.mollusk.sysvars.clock.epoch < *i { + ctx.advance_epoch().await; + } + + // Fund and delegate + let staked_amount = MINIMUM_DELEGATION; + let current_lamports = stake_accounts[idx].lamports(); + stake_accounts[idx].set_lamports(current_lamports + staked_amount); + ctx.fund_stake_account(&stakes[idx], staked_amount).await; + + ctx.delegate_stake(&stakes[idx], &mut stake_accounts[idx], &stakers[idx]) + .await; + } + + while ctx.mollusk.sysvars.clock.epoch < 30 { + let stake_refs: Vec<_> = stakes.iter().zip(stake_accounts.iter()).collect(); + ctx.advance_and_compare_stakes(&stake_refs).await; + } +} + +#[tokio::test] +async fn test_mixed_lifecycle_stress() { + let mut ctx = DualContext::new().await; + + let total_stakes = 20; + let mut stakes = Vec::new(); + let mut stake_accounts = Vec::new(); + let mut stakers = Vec::new(); + + // Create all stakes + for _ in 0..total_stakes { + let stake = ctx.create_blank_stake_account().await; + let staker = Keypair::new(); + let authorized = Authorized { + staker: staker.pubkey(), + withdrawer: Pubkey::new_unique(), + }; + + let mut stake_account = ctx + .initialize_stake_account(&stake, &authorized, &Lockup::default()) + .await; + + let staked_amount = MINIMUM_DELEGATION; + stake_account.set_lamports(stake_account.lamports() + staked_amount); + ctx.fund_stake_account(&stake, staked_amount).await; + + stakes.push(stake); + stake_accounts.push(stake_account); + stakers.push(staker); + } + + // Create various lifecycle states: + // 5 activating (delegate in epoch 0) + for i in 0..5 { + ctx.delegate_stake(&stakes[i], &mut stake_accounts[i], &stakers[i]) + .await; + } + + ctx.advance_epoch().await; // Epoch 1 + + // 5 active (delegated in epoch 0, now partially active) + // Already done above, just advancing + + // 5 more activating (delegate in epoch 1) + for i in 5..10 { + ctx.delegate_stake(&stakes[i], &mut stake_accounts[i], &stakers[i]) + .await; + } + + ctx.advance_epoch().await; // Epoch 2 + + // 5 deactivating (deactivate some active ones) + for i in 0..5 { + ctx.deactivate_stake(&stakes[i], &mut stake_accounts[i], &stakers[i]) + .await; + } + + for _ in 0..10 { + let stake_refs: Vec<_> = stakes.iter().zip(stake_accounts.iter()).collect(); + ctx.advance_and_compare_stakes(&stake_refs).await; + } +} + +// Test repeated cycles of delegation and deactivation with full transitions +#[test_case(2 ; "two_cycles")] +#[test_case(3 ; "three_cycles")] +#[tokio::test] +async fn test_repeated_delegation_cycles(num_cycles: usize) { + let mut ctx = DualContext::new().await; + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + for _ in 0..num_cycles { + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + ctx.advance_until_fully_activated(&stake, &stake_account, 20) + .await; + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + ctx.advance_until_fully_deactivated(&stake, &stake_account, 20) + .await; + } +} + +#[tokio::test] +async fn test_delegate_after_full_deactivation_different_validator() { + let mut ctx = DualContext::new().await; + + let vote_account_b = Pubkey::new_unique(); + let vote_account_b_data = create_vote_account(); + ctx.program_test_ctx + .set_account(&vote_account_b, &vote_account_b_data.clone()); + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_activated(&stake, &stake_account, 20) + .await; + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_deactivated(&stake, &stake_account, 20) + .await; + + ctx.delegate_stake_to( + &stake, + &mut stake_account, + &staker, + &vote_account_b, + &vote_account_b_data, + ) + .await; + + ctx.advance_until_fully_activated(&stake, &stake_account, 20) + .await; +} + +#[tokio::test] +async fn test_delegate_after_deactivation_during_warmup() { + let mut ctx = DualContext::new().await; + + let vote_account_b = Pubkey::new_unique(); + let vote_account_b_data = create_vote_account(); + ctx.program_test_ctx + .set_account(&vote_account_b, &vote_account_b_data.clone()); + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_and_compare_stake(&stake, &stake_account).await; + ctx.advance_and_compare_stake(&stake, &stake_account).await; + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_deactivated(&stake, &stake_account, 20) + .await; + + ctx.delegate_stake_to( + &stake, + &mut stake_account, + &staker, + &vote_account_b, + &vote_account_b_data, + ) + .await; + + ctx.advance_until_fully_activated(&stake, &stake_account, 20) + .await; +} + +#[tokio::test] +async fn test_delegate_during_deactivation_cooldown() { + let mut ctx = DualContext::new().await; + + let vote_account_b = Pubkey::new_unique(); + let vote_account_b_data = create_vote_account(); + ctx.program_test_ctx + .set_account(&vote_account_b, &vote_account_b_data.clone()); + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_activated(&stake, &stake_account, 20) + .await; + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_and_compare_stake(&stake, &stake_account).await; + ctx.advance_and_compare_stake(&stake, &stake_account).await; + + ctx.delegate_stake_to( + &stake, + &mut stake_account, + &staker, + &vote_account_b, + &vote_account_b_data, + ) + .await; + + ctx.compare_account_state(&stake, &stake_account).await; + + let banks_effective = ctx.get_banks_effective_stake(&stake).await; + let mollusk_effective = ctx.get_mollusk_effective_stake(&stake_account); + assert_eq!( + banks_effective, mollusk_effective, + "Effective stake mismatch immediately after delegating during deactivation" + ); + + ctx.advance_until_fully_activated(&stake, &stake_account, 20) + .await; +} + +#[tokio::test] +async fn test_old_warmup_rate_activation() { + let mut ctx = DualContext::new().await; + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + for _ in 0..10 { + ctx.advance_and_compare_stake_with_rate(&stake, &stake_account, None) + .await; + } +} + +#[tokio::test] +async fn test_old_warmup_rate_deactivation() { + let mut ctx = DualContext::new().await; + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + for _ in 0..5 { + ctx.advance_and_compare_stake_with_rate(&stake, &stake_account, None) + .await; + } + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + + for _ in 0..10 { + ctx.advance_and_compare_stake_with_rate(&stake, &stake_account, None) + .await; + } +} + +#[tokio::test] +async fn test_warmup_rate_transition() { + let mut ctx = DualContext::new().await; + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(MINIMUM_DELEGATION, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + for _ in 0..5 { + ctx.advance_and_compare_stake_with_rate(&stake, &stake_account, None) + .await; + } + + let transition_epoch = ctx.mollusk.sysvars.clock.epoch; + + for _ in 0..10 { + ctx.advance_and_compare_stake_with_rate(&stake, &stake_account, Some(transition_epoch)) + .await; + } +} + +#[tokio::test] +async fn test_massive_stake_relative_to_background() { + let mut ctx = DualContext::new().await; + + // Get the background stake - use epoch 0's effective stake as the baseline + let banks_stake_history = ctx.get_banks_stake_history().await; + let background_effective = banks_stake_history + .get(0) + .map(|entry| entry.effective) + .expect("Epoch 0 must have background stake"); + + // Stake 50% of background effective stake - this is MASSIVE and will significantly + // affect warmup rates (warmup is limited by new_stake relative to existing effective) + let staked_amount = background_effective / 2; + + if staked_amount < MINIMUM_DELEGATION { + panic!( + "Bad test: background stake {} too small (need at least {})", + background_effective, + MINIMUM_DELEGATION * 2 + ); + } + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(staked_amount, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + let epochs_to_activate = ctx + .advance_until_fully_activated(&stake, &stake_account, 50) + .await; + + for epoch_offset in 0..epochs_to_activate { + let epoch = ctx.mollusk.sysvars.clock.epoch - epochs_to_activate + epoch_offset; + ctx.verify_background_stake_preservation(epoch, background_effective) + .await; + } +} + +#[tokio::test] +async fn test_massive_stake_deactivation() { + let mut ctx = DualContext::new().await; + + let banks_stake_history = ctx.get_banks_stake_history().await; + let background_effective = banks_stake_history + .get(0) + .map(|entry| entry.effective) + .expect("Epoch 0 must have background stake"); + + let staked_amount = background_effective / 2; + + if staked_amount < MINIMUM_DELEGATION { + panic!( + "Bad test: background stake {} too small (need at least {})", + background_effective, + MINIMUM_DELEGATION * 2 + ); + } + + let (stake, mut stake_account, staker) = ctx + .create_and_fund_stake(staked_amount, &Lockup::default()) + .await; + + ctx.delegate_stake(&stake, &mut stake_account, &staker) + .await; + + ctx.advance_until_fully_activated(&stake, &stake_account, 50) + .await; + + ctx.deactivate_stake(&stake, &mut stake_account, &staker) + .await; + + let epochs_to_deactivate = ctx + .advance_until_fully_deactivated(&stake, &stake_account, 50) + .await; + + for epoch_offset in 0..epochs_to_deactivate { + let epoch = ctx.mollusk.sysvars.clock.epoch - epochs_to_deactivate + epoch_offset; + ctx.verify_background_stake_preservation(epoch, background_effective) + .await; + } +}