Skip to content

Commit 1293590

Browse files
committed
StakeLifecycle, Deactivate tests
1 parent 080449a commit 1293590

File tree

5 files changed

+347
-89
lines changed

5 files changed

+347
-89
lines changed

program/tests/deactivate.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#![allow(clippy::arithmetic_side_effects)]
2+
3+
mod helpers;
4+
5+
use {
6+
helpers::{context::StakeTestContext, lifecycle::StakeLifecycle},
7+
mollusk_svm::result::Check,
8+
solana_account::ReadableAccount,
9+
solana_program_error::ProgramError,
10+
solana_pubkey::Pubkey,
11+
solana_stake_client::instructions::{DeactivateBuilder, DelegateStakeBuilder},
12+
solana_stake_interface::{error::StakeError, state::StakeStateV2},
13+
solana_stake_program::id,
14+
test_case::test_case,
15+
};
16+
17+
#[test_case(false; "activating")]
18+
#[test_case(true; "active")]
19+
fn test_deactivate(activate: bool) {
20+
let mut ctx = StakeTestContext::with_delegation();
21+
let min_delegation = ctx.minimum_delegation.unwrap();
22+
let rent_exempt_reserve = ctx.rent_exempt_reserve;
23+
let staker = ctx.staker;
24+
let withdrawer = ctx.withdrawer;
25+
26+
let (stake, mut stake_account) = ctx
27+
.stake_account(StakeLifecycle::Initialized)
28+
.staked_amount(min_delegation)
29+
.build();
30+
31+
let (vote, vote_account_data) = ctx.vote_account.clone().unwrap();
32+
33+
// Deactivating an undelegated account fails
34+
ctx.checks(&[Check::err(ProgramError::InvalidAccountData)])
35+
.test_missing_signers(false)
36+
.execute(
37+
DeactivateBuilder::new()
38+
.stake(stake)
39+
.stake_authority(staker)
40+
.instruction(),
41+
&[(&stake, &stake_account)],
42+
);
43+
44+
// Delegate
45+
let result = ctx.execute(
46+
DelegateStakeBuilder::new()
47+
.stake(stake)
48+
.vote(vote)
49+
.unused(Pubkey::new_unique())
50+
.stake_authority(staker)
51+
.instruction(),
52+
&[(&stake, &stake_account), (&vote, &vote_account_data)],
53+
);
54+
stake_account = result.resulting_accounts[0].1.clone().into();
55+
56+
if activate {
57+
// Advance epoch to activate
58+
let current_slot = ctx.mollusk.sysvars.clock.slot;
59+
let slots_per_epoch = ctx.mollusk.sysvars.epoch_schedule.slots_per_epoch;
60+
ctx.mollusk.warp_to_slot(current_slot + slots_per_epoch);
61+
}
62+
63+
// Deactivate with withdrawer fails
64+
ctx.checks(&[Check::err(ProgramError::MissingRequiredSignature)])
65+
.test_missing_signers(false)
66+
.execute(
67+
DeactivateBuilder::new()
68+
.stake(stake)
69+
.stake_authority(withdrawer)
70+
.instruction(),
71+
&[(&stake, &stake_account)],
72+
);
73+
74+
// Deactivate succeeds
75+
let result = ctx
76+
.checks(&[
77+
Check::success(),
78+
Check::all_rent_exempt(),
79+
Check::account(&stake)
80+
.lamports(rent_exempt_reserve + min_delegation)
81+
.owner(&id())
82+
.space(StakeStateV2::size_of())
83+
.build(),
84+
])
85+
.execute(
86+
DeactivateBuilder::new()
87+
.stake(stake)
88+
.stake_authority(staker)
89+
.instruction(),
90+
&[(&stake, &stake_account)],
91+
);
92+
stake_account = result.resulting_accounts[0].1.clone().into();
93+
94+
let clock = ctx.mollusk.sysvars.clock.clone();
95+
let stake_state: StakeStateV2 = bincode::deserialize(stake_account.data()).unwrap();
96+
if let StakeStateV2::Stake(_, stake_data, _) = stake_state {
97+
assert_eq!(stake_data.delegation.deactivation_epoch, clock.epoch);
98+
} else {
99+
panic!("Expected StakeStateV2::Stake");
100+
}
101+
102+
// Deactivate again fails
103+
ctx.checks(&[Check::err(StakeError::AlreadyDeactivated.into())])
104+
.test_missing_signers(false)
105+
.execute(
106+
DeactivateBuilder::new()
107+
.stake(stake)
108+
.stake_authority(staker)
109+
.instruction(),
110+
&[(&stake, &stake_account)],
111+
);
112+
113+
// Advance epoch
114+
let current_slot = ctx.mollusk.sysvars.clock.slot;
115+
let slots_per_epoch = ctx.mollusk.sysvars.epoch_schedule.slots_per_epoch;
116+
ctx.mollusk.warp_to_slot(current_slot + slots_per_epoch);
117+
118+
// Deactivate again still fails
119+
ctx.checks(&[Check::err(StakeError::AlreadyDeactivated.into())])
120+
.test_missing_signers(false)
121+
.execute(
122+
DeactivateBuilder::new()
123+
.stake(stake)
124+
.stake_authority(staker)
125+
.instruction(),
126+
&[(&stake, &stake_account)],
127+
);
128+
}

program/tests/helpers/context.rs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use {
22
super::{
33
execution::ExecutionWithChecks,
44
lifecycle::StakeLifecycle,
5-
utils::{add_sysvars, STAKE_RENT_EXEMPTION},
5+
utils::{add_sysvars, create_vote_account, STAKE_RENT_EXEMPTION},
66
},
77
mollusk_svm::{result::Check, Mollusk},
88
solana_account::AccountSharedData,
@@ -65,7 +65,24 @@ impl StakeAccountBuilder<'_> {
6565
/// Build the stake account and return (pubkey, account_data)
6666
pub fn build(self) -> (Pubkey, AccountSharedData) {
6767
let stake_pubkey = self.stake_pubkey.unwrap_or_else(Pubkey::new_unique);
68-
let account = self.lifecycle.create_uninitialized_account();
68+
let vote_account_ref = self.vote_account.as_ref().unwrap_or_else(|| {
69+
self.ctx
70+
.vote_account
71+
.as_ref()
72+
.map(|(pk, _)| pk)
73+
.expect("vote_account required for this lifecycle")
74+
});
75+
let account = self.lifecycle.create_stake_account_fully_specified(
76+
&mut self.ctx.mollusk,
77+
&stake_pubkey,
78+
vote_account_ref,
79+
self.staked_amount,
80+
self.stake_authority.as_ref().unwrap_or(&self.ctx.staker),
81+
self.withdraw_authority
82+
.as_ref()
83+
.unwrap_or(&self.ctx.withdrawer),
84+
self.lockup.as_ref().unwrap_or(&Lockup::default()),
85+
);
6986
(stake_pubkey, account)
7087
}
7188
}
@@ -75,6 +92,9 @@ pub struct StakeTestContext {
7592
pub rent_exempt_reserve: u64,
7693
pub staker: Pubkey,
7794
pub withdrawer: Pubkey,
95+
pub minimum_delegation: Option<u64>,
96+
/// Vote account (pubkey, account_data) for delegation tests
97+
pub vote_account: Option<(Pubkey, AccountSharedData)>,
7898
}
7999

80100
impl StakeTestContext {
@@ -85,6 +105,21 @@ impl StakeTestContext {
85105
rent_exempt_reserve: STAKE_RENT_EXEMPTION,
86106
staker: Pubkey::new_unique(),
87107
withdrawer: Pubkey::new_unique(),
108+
minimum_delegation: None,
109+
vote_account: None,
110+
}
111+
}
112+
113+
pub fn with_delegation() -> Self {
114+
let mollusk = Mollusk::new(&id(), "solana_stake_program");
115+
let minimum_delegation = solana_stake_program::get_minimum_delegation();
116+
Self {
117+
mollusk,
118+
rent_exempt_reserve: STAKE_RENT_EXEMPTION,
119+
staker: Pubkey::new_unique(),
120+
withdrawer: Pubkey::new_unique(),
121+
minimum_delegation: Some(minimum_delegation),
122+
vote_account: Some((Pubkey::new_unique(), create_vote_account())),
88123
}
89124
}
90125

@@ -95,6 +130,8 @@ impl StakeTestContext {
95130
/// let (stake, account) = ctx
96131
/// .stake_account(StakeLifecycle::Active)
97132
/// .staked_amount(1_000_000)
133+
/// .stake_account(StakeLifecycle::Active)
134+
/// .staked_amount(1_000_000)
98135
/// .build();
99136
/// ```
100137
pub fn stake_account(&mut self, lifecycle: StakeLifecycle) -> StakeAccountBuilder<'_> {
@@ -117,6 +154,25 @@ impl StakeTestContext {
117154
ExecutionWithChecks::new(self, checks)
118155
}
119156

157+
/// Create a lockup that expires in the future
158+
pub fn create_future_lockup(&self, epochs_ahead: u64) -> Lockup {
159+
Lockup {
160+
unix_timestamp: 0,
161+
epoch: self.mollusk.sysvars.clock.epoch + epochs_ahead,
162+
custodian: Pubkey::new_unique(),
163+
}
164+
}
165+
166+
/// Create a lockup that's currently in force (far future)
167+
pub fn create_in_force_lockup(&self) -> Lockup {
168+
self.create_future_lockup(1_000_000)
169+
}
170+
171+
/// Create a second vote account (for testing different vote accounts)
172+
pub fn create_second_vote_account(&self) -> (Pubkey, AccountSharedData) {
173+
(Pubkey::new_unique(), create_vote_account())
174+
}
175+
120176
/// Execute an instruction with default success checks and missing signer testing
121177
///
122178
/// Usage: `ctx.execute(instruction, accounts)`
@@ -154,7 +210,7 @@ impl StakeTestContext {
154210

155211
impl Default for StakeTestContext {
156212
fn default() -> Self {
157-
Self::new()
213+
Self::with_delegation()
158214
}
159215
}
160216

program/tests/helpers/lifecycle.rs

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
use {
2-
super::utils::STAKE_RENT_EXEMPTION, solana_account::AccountSharedData,
3-
solana_stake_interface::state::StakeStateV2, solana_stake_program::id,
2+
super::utils::{add_sysvars, create_vote_account, STAKE_RENT_EXEMPTION},
3+
mollusk_svm::Mollusk,
4+
solana_account::{Account, AccountSharedData, WritableAccount},
5+
solana_pubkey::Pubkey,
6+
solana_stake_interface::{
7+
instruction as ixn,
8+
state::{Authorized, Lockup, StakeStateV2},
9+
},
10+
solana_stake_program::id,
411
};
512

613
/// Lifecycle states for stake accounts in tests
@@ -16,14 +23,134 @@ pub enum StakeLifecycle {
1623
}
1724

1825
impl StakeLifecycle {
19-
/// Create an uninitialized stake account
20-
pub fn create_uninitialized_account(self) -> AccountSharedData {
21-
AccountSharedData::new_data_with_space(
22-
STAKE_RENT_EXEMPTION,
23-
&StakeStateV2::Uninitialized,
24-
StakeStateV2::size_of(),
25-
&id(),
26+
/// Create a stake account with full specification of authorities and lockup
27+
#[allow(clippy::too_many_arguments)]
28+
pub fn create_stake_account_fully_specified(
29+
self,
30+
mollusk: &mut Mollusk,
31+
// tracker: &mut StakeTracker, // added in subsequent PR
32+
stake_pubkey: &Pubkey,
33+
vote_account: &Pubkey,
34+
staked_amount: u64,
35+
staker: &Pubkey,
36+
withdrawer: &Pubkey,
37+
lockup: &Lockup,
38+
) -> AccountSharedData {
39+
let is_closed = self == StakeLifecycle::Closed;
40+
41+
// Create base account
42+
let mut stake_account = if is_closed {
43+
let mut account = Account::create(STAKE_RENT_EXEMPTION, vec![], id(), false, u64::MAX);
44+
// Add staked_amount even for closed accounts (matches program-test behavior)
45+
if staked_amount > 0 {
46+
account.lamports += staked_amount;
47+
}
48+
account.into()
49+
} else {
50+
Account::create(
51+
STAKE_RENT_EXEMPTION + staked_amount,
52+
vec![0; StakeStateV2::size_of()],
53+
id(),
54+
false,
55+
u64::MAX,
56+
)
57+
.into()
58+
};
59+
60+
if is_closed {
61+
return stake_account;
62+
}
63+
64+
let authorized = Authorized {
65+
staker: *staker,
66+
withdrawer: *withdrawer,
67+
};
68+
69+
// Initialize if needed
70+
if self >= StakeLifecycle::Initialized {
71+
let stake_state = StakeStateV2::Initialized(solana_stake_interface::state::Meta {
72+
rent_exempt_reserve: STAKE_RENT_EXEMPTION,
73+
authorized,
74+
lockup: *lockup,
75+
});
76+
bincode::serialize_into(stake_account.data_as_mut_slice(), &stake_state).unwrap();
77+
}
78+
79+
// Delegate if needed
80+
if self >= StakeLifecycle::Activating {
81+
let instruction = ixn::delegate_stake(stake_pubkey, staker, vote_account);
82+
83+
let accounts = vec![
84+
(*stake_pubkey, stake_account.clone()),
85+
(*vote_account, create_vote_account()),
86+
];
87+
88+
// Use add_sysvars to provide clock, stake history, and config accounts
89+
let accounts_with_sysvars = add_sysvars(mollusk, &instruction, accounts);
90+
let result = mollusk.process_instruction(&instruction, &accounts_with_sysvars);
91+
stake_account = result.resulting_accounts[0].1.clone().into();
92+
93+
// Track delegation in the tracker
94+
// let activation_epoch = mollusk.sysvars.clock.epoch;
95+
// TODO: uncomment in subsequent PR (add `tracker.track_delegation` here)
96+
// tracker.track_delegation(stake_pubkey, staked_amount, activation_epoch, vote_account);
97+
}
98+
99+
// Advance epoch to activate if needed (Active and beyond)
100+
if self >= StakeLifecycle::Active {
101+
// With background stake in tracker, just warp 1 epoch
102+
// The background stake provides baseline for instant partial activation
103+
let slots_per_epoch = mollusk.sysvars.epoch_schedule.slots_per_epoch;
104+
let current_slot = mollusk.sysvars.clock.slot;
105+
let target_slot = current_slot + slots_per_epoch;
106+
107+
// TODO: use `warp_to_slot_with_stake_tracking` here (in subsequent PR)
108+
mollusk.warp_to_slot(target_slot);
109+
}
110+
111+
// Deactivate if needed
112+
if self >= StakeLifecycle::Deactivating {
113+
let instruction = ixn::deactivate_stake(stake_pubkey, staker);
114+
115+
let accounts = vec![(*stake_pubkey, stake_account.clone())];
116+
117+
// Use add_sysvars to provide clock account
118+
let accounts_with_sysvars = add_sysvars(mollusk, &instruction, accounts);
119+
let result = mollusk.process_instruction(&instruction, &accounts_with_sysvars);
120+
stake_account = result.resulting_accounts[0].1.clone().into();
121+
122+
// Track deactivation in the tracker
123+
// let deactivation_epoch = mollusk.sysvars.clock.epoch;
124+
// TODO: uncomment in subsequent PR
125+
// tracker.track_deactivation(stake_pubkey, deactivation_epoch);
126+
}
127+
128+
// Advance epoch to fully deactivate if needed (Deactive lifecycle)
129+
// Matches program_test.rs line 978-983: advance_epoch once to fully deactivate
130+
if self == StakeLifecycle::Deactive {
131+
// With background stake, advance 1 epoch for deactivation
132+
// Background provides the baseline for instant partial deactivation
133+
let slots_per_epoch = mollusk.sysvars.epoch_schedule.slots_per_epoch;
134+
let current_slot = mollusk.sysvars.clock.slot;
135+
let target_slot = current_slot + slots_per_epoch;
136+
137+
// TODO: use `warp_to_slot_with_stake_tracking` here (in subsequent PR)
138+
mollusk.warp_to_slot(target_slot);
139+
}
140+
141+
stake_account
142+
}
143+
144+
/// Whether this lifecycle stage enforces minimum delegation for split
145+
pub fn split_minimum_enforced(&self) -> bool {
146+
matches!(
147+
self,
148+
Self::Activating | Self::Active | Self::Deactivating | Self::Deactive
26149
)
27-
.unwrap()
150+
}
151+
152+
/// Whether this lifecycle stage enforces minimum delegation for withdraw
153+
pub fn withdraw_minimum_enforced(&self) -> bool {
154+
matches!(self, Self::Activating | Self::Active | Self::Deactivating)
28155
}
29156
}

0 commit comments

Comments
 (0)