diff --git a/Cargo.lock b/Cargo.lock index 2b2eb531..83b77c3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6808,6 +6808,7 @@ dependencies = [ "assert_matches", "bincode", "mollusk-svm", + "mollusk-svm-result", "proptest", "rand 0.10.0", "solana-account 3.2.0", diff --git a/interface/src/state.rs b/interface/src/state.rs index b7964997..9f25ce9a 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -415,6 +415,11 @@ impl Authorized { derive(serde_derive::Deserialize, serde_derive::Serialize) )] pub struct Meta { + #[deprecated( + since = "3.0.1", + note = "Stake account rent must be calculated via the `Rent` sysvar. \ + This value will cease to be correct once lamports-per-byte is adjusted." + )] pub rent_exempt_reserve: u64, pub authorized: Authorized, pub lockup: Lockup, diff --git a/program/Cargo.toml b/program/Cargo.toml index 36f03834..55f4244d 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -29,6 +29,7 @@ agave-feature-set = "3.0.0" arbitrary = { version = "1.4.2", features = ["derive"] } assert_matches = "1.5.0" mollusk-svm = { version = "0.7.2", features = ["all-builtins"] } +mollusk-svm-result = "0.7.2" proptest = "1.10.0" rand = "0.10.0" solana-account = { version = "3.2.0", features = ["bincode"] } diff --git a/program/src/helpers/delegate.rs b/program/src/helpers/delegate.rs index 194a34e0..0703628b 100644 --- a/program/src/helpers/delegate.rs +++ b/program/src/helpers/delegate.rs @@ -5,7 +5,7 @@ use { solana_pubkey::Pubkey, solana_stake_interface::{ error::StakeError, - state::{Delegation, Meta, Stake}, + state::{Delegation, Stake}, }, }; @@ -32,9 +32,9 @@ pub(crate) fn new_stake( /// an error. pub(crate) fn validate_delegated_amount( account: &AccountInfo, - meta: &Meta, + rent_exempt_reserve: u64, ) -> Result { - let stake_amount = account.lamports().saturating_sub(meta.rent_exempt_reserve); // can't stake the rent + let stake_amount = account.lamports().saturating_sub(rent_exempt_reserve); // can't stake the rent // Stake accounts may be initialized with a stake amount below the minimum // delegation so check that the minimum is met before delegation. diff --git a/program/src/helpers/merge.rs b/program/src/helpers/merge.rs index 84ffa097..6228379d 100644 --- a/program/src/helpers/merge.rs +++ b/program/src/helpers/merge.rs @@ -12,7 +12,7 @@ use { #[derive(Clone, Debug, PartialEq)] pub(crate) enum MergeKind { Inactive(Meta, u64, StakeFlags), - ActivationEpoch(Meta, Stake, StakeFlags), + ActivationEpoch(Meta, Stake, u64, StakeFlags), FullyActive(Meta, Stake), } @@ -20,7 +20,7 @@ impl MergeKind { pub(crate) fn meta(&self) -> &Meta { match self { Self::Inactive(meta, _, _) => meta, - Self::ActivationEpoch(meta, _, _) => meta, + Self::ActivationEpoch(meta, _, _, _) => meta, Self::FullyActive(meta, _) => meta, } } @@ -28,7 +28,7 @@ impl MergeKind { pub(crate) fn active_stake(&self) -> Option<&Stake> { match self { Self::Inactive(_, _, _) => None, - Self::ActivationEpoch(_, stake, _) => Some(stake), + Self::ActivationEpoch(_, stake, _, _) => Some(stake), Self::FullyActive(_, stake) => Some(stake), } } @@ -51,7 +51,12 @@ impl MergeKind { match (status.effective, status.activating, status.deactivating) { (0, 0, 0) => Ok(Self::Inactive(*meta, stake_lamports, *stake_flags)), - (0, _, _) => Ok(Self::ActivationEpoch(*meta, *stake, *stake_flags)), + (0, _, _) => Ok(Self::ActivationEpoch( + *meta, + *stake, + stake_lamports, + *stake_flags, + )), (_, 0, 0) => Ok(Self::FullyActive(*meta, *stake)), _ => { let err = StakeError::MergeTransientStake; @@ -114,9 +119,9 @@ impl MergeKind { .unwrap_or(Ok(()))?; let merged_state = match (self, source) { (Self::Inactive(_, _, _), Self::Inactive(_, _, _)) => None, - (Self::Inactive(_, _, _), Self::ActivationEpoch(_, _, _)) => None, + (Self::Inactive(_, _, _), Self::ActivationEpoch(_, _, _, _)) => None, ( - Self::ActivationEpoch(meta, mut stake, stake_flags), + Self::ActivationEpoch(meta, mut stake, _, stake_flags), Self::Inactive(_, source_lamports, source_stake_flags), ) => { stake.delegation.stake = checked_add(stake.delegation.stake, source_lamports)?; @@ -127,13 +132,9 @@ impl MergeKind { )) } ( - Self::ActivationEpoch(meta, mut stake, stake_flags), - Self::ActivationEpoch(source_meta, source_stake, source_stake_flags), + Self::ActivationEpoch(meta, mut stake, _, stake_flags), + Self::ActivationEpoch(_, source_stake, source_lamports, source_stake_flags), ) => { - let source_lamports = checked_add( - source_meta.rent_exempt_reserve, - source_stake.delegation.stake, - )?; merge_delegation_stake_and_credits_observed( &mut stake, source_lamports, @@ -525,7 +526,7 @@ mod tests { &stake_history ) .unwrap(), - MergeKind::ActivationEpoch(meta, stake, StakeFlags::empty()), + MergeKind::ActivationEpoch(meta, stake, stake_account.lamports(), StakeFlags::empty()), ); // all paritially activated, transient epochs fail @@ -653,20 +654,31 @@ mod tests { #[test] fn test_merge_kind_merge() { let clock = Clock::default(); - let lamports = 424242; + let rent_exempt_reserve = 42; + let activating_stake = 4242; + let inactive_total_lamports = 424242; let meta = Meta { - rent_exempt_reserve: 42, + rent_exempt_reserve, ..Meta::default() }; let stake = Stake { delegation: Delegation { - stake: 4242, + stake: activating_stake, ..Delegation::default() }, ..Stake::default() }; - let inactive = MergeKind::Inactive(Meta::default(), lamports, StakeFlags::empty()); - let activation_epoch = MergeKind::ActivationEpoch(meta, stake, StakeFlags::empty()); + let inactive = MergeKind::Inactive( + Meta::default(), + inactive_total_lamports, + StakeFlags::empty(), + ); + let activation_epoch = MergeKind::ActivationEpoch( + meta, + stake, + activating_stake + rent_exempt_reserve, + StakeFlags::empty(), + ); let fully_active = MergeKind::FullyActive(meta, stake); assert_eq!( @@ -703,7 +715,7 @@ mod tests { .unwrap() .unwrap(); let delegation = new_state.delegation().unwrap(); - assert_eq!(delegation.stake, stake.delegation.stake + lamports); + assert_eq!(delegation.stake, activating_stake + inactive_total_lamports); let new_state = activation_epoch .clone() @@ -711,10 +723,7 @@ mod tests { .unwrap() .unwrap(); let delegation = new_state.delegation().unwrap(); - assert_eq!( - delegation.stake, - 2 * stake.delegation.stake + meta.rent_exempt_reserve - ); + assert_eq!(delegation.stake, 2 * activating_stake + rent_exempt_reserve); let new_state = fully_active .clone() @@ -722,7 +731,7 @@ mod tests { .unwrap() .unwrap(); let delegation = new_state.delegation().unwrap(); - assert_eq!(delegation.stake, 2 * stake.delegation.stake); + assert_eq!(delegation.stake, 2 * activating_stake); } #[test] @@ -752,8 +761,18 @@ mod tests { }; // activating stake merge, match credits observed - let activation_epoch_a = MergeKind::ActivationEpoch(meta, stake_a, StakeFlags::empty()); - let activation_epoch_b = MergeKind::ActivationEpoch(meta, stake_b, StakeFlags::empty()); + let activation_epoch_a = MergeKind::ActivationEpoch( + meta, + stake_a, + delegation_a + rent_exempt_reserve, + StakeFlags::empty(), + ); + let activation_epoch_b = MergeKind::ActivationEpoch( + meta, + stake_b, + delegation_b + rent_exempt_reserve, + StakeFlags::empty(), + ); let new_stake = activation_epoch_a .merge(activation_epoch_b, &clock) .unwrap() @@ -787,8 +806,18 @@ mod tests { }, credits_observed: credits_b, }; - let activation_epoch_a = MergeKind::ActivationEpoch(meta, stake_a, StakeFlags::empty()); - let activation_epoch_b = MergeKind::ActivationEpoch(meta, stake_b, StakeFlags::empty()); + let activation_epoch_a = MergeKind::ActivationEpoch( + meta, + stake_a, + delegation_a + rent_exempt_reserve, + StakeFlags::empty(), + ); + let activation_epoch_b = MergeKind::ActivationEpoch( + meta, + stake_b, + delegation_b + rent_exempt_reserve, + StakeFlags::empty(), + ); let new_stake = activation_epoch_a .merge(activation_epoch_b, &clock) .unwrap() diff --git a/program/src/lib.rs b/program/src/lib.rs index eee520d9..ce11aa0d 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -18,6 +18,15 @@ solana_pubkey::declare_id!("Stake11111111111111111111111111111111111111"); // we can pretend the rate has always beein 9% without issue. so we do that const PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH: Option = Some(0); +// Historically, `Meta.rent_exempt_reserve` contained the canonical rent +// reservation for a stake account. This implicitly depended on +// lamports-per-byte remaining fixed over time. This value will be allowed +// to fluctuate, which means the stake program must calculate rent from the +// `Rent` sysvar directly. However, downstream programs may still rely on the +// `Meta` value being set. For maximum predictability, we set `rent_exempt_reserve` +// to its historical value unconditionally, but ignore it in the stake program. +const PSEUDO_RENT_EXEMPT_RESERVE: u64 = 2_282_880; + /// The minimum stake amount that can be delegated, in lamports. /// NOTE: This is also used to calculate the minimum balance of a delegated /// stake account, which is the rent exempt reserve _plus_ the minimum stake diff --git a/program/src/processor.rs b/program/src/processor.rs index b602fb02..a5599b4c 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1,5 +1,5 @@ use { - crate::{helpers::*, id, PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH}, + crate::{helpers::*, id, PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, PSEUDO_RENT_EXEMPT_RESERVE}, solana_account_info::{next_account_info, AccountInfo}, solana_clock::Clock, solana_cpi::set_return_data, @@ -145,9 +145,9 @@ fn do_initialize( let rent_exempt_reserve = rent.minimum_balance(stake_account_info.data_len()); if stake_account_info.lamports() >= rent_exempt_reserve { let stake_state = StakeStateV2::Initialized(Meta { - rent_exempt_reserve, authorized, lockup, + rent_exempt_reserve: PSEUDO_RENT_EXEMPT_RESERVE, }); set_stake_state(stake_account_info, &stake_state) @@ -228,7 +228,7 @@ fn do_set_lockup( fn move_stake_or_lamports_shared_checks( source_stake_account_info: &AccountInfo, - lamports: u64, + move_amount: u64, destination_stake_account_info: &AccountInfo, stake_authority_info: &AccountInfo, ) -> Result<(MergeKind, MergeKind), ProgramError> { @@ -248,7 +248,7 @@ fn move_stake_or_lamports_shared_checks( } // must move something - if lamports == 0 { + if move_amount == 0 { return Err(ProgramError::InvalidArgument); } @@ -403,11 +403,14 @@ impl Processor { } }; + let rent = &Rent::get()?; let clock = &Clock::get()?; let stake_history = &StakeHistorySysvar(clock.epoch); let vote_state = get_vote_state(vote_account_info)?; + let rent_exempt_reserve = rent.minimum_balance(stake_account_info.data_len()); + match get_stake_state(stake_account_info)? { StakeStateV2::Initialized(meta) => { meta.authorized @@ -415,7 +418,7 @@ impl Processor { .map_err(to_program_error)?; let ValidatedDelegatedInfo { stake_amount } = - validate_delegated_amount(stake_account_info, &meta)?; + validate_delegated_amount(stake_account_info, rent_exempt_reserve)?; let stake = new_stake( stake_amount, @@ -437,7 +440,7 @@ impl Processor { // Compute the maximum stake allowed to (re)delegate let ValidatedDelegatedInfo { stake_amount } = - validate_delegated_amount(stake_account_info, &meta)?; + validate_delegated_amount(stake_account_info, rent_exempt_reserve)?; // Get current activation status at this epoch let effective_stake = stake.delegation.stake( @@ -515,6 +518,8 @@ impl Processor { return Err(ProgramError::InsufficientFunds); } + let source_rent_exempt_reserve = rent.minimum_balance(source_stake_account_info.data_len()); + let destination_data_len = destination_stake_account_info.data_len(); if destination_data_len != StakeStateV2::size_of() { return Err(ProgramError::InvalidAccountData); @@ -539,7 +544,7 @@ impl Processor { source_status.effective > 0 || source_status.activating > 0; let mut dest_meta = source_meta; - dest_meta.rent_exempt_reserve = destination_rent_exempt_reserve; + dest_meta.rent_exempt_reserve = PSEUDO_RENT_EXEMPT_RESERVE; (is_active_or_activating, Some(dest_meta)) } @@ -550,7 +555,7 @@ impl Processor { .map_err(to_program_error)?; let mut dest_meta = source_meta; - dest_meta.rent_exempt_reserve = destination_rent_exempt_reserve; + dest_meta.rent_exempt_reserve = PSEUDO_RENT_EXEMPT_RESERVE; (false, Some(dest_meta)) } @@ -614,9 +619,6 @@ impl Processor { // special case: if stake is fully inactive, we only care that both accounts meet rent-exemption if !is_active_or_activating { - let source_rent_exempt_reserve = - rent.minimum_balance(source_stake_account_info.data_len()); - let mut destination_stake_state = source_stake_state; match (&mut destination_stake_state, option_dest_meta) { (StakeStateV2::Stake(meta, _, _), Some(dest_meta)) @@ -682,7 +684,7 @@ impl Processor { if source_lamport_balance .saturating_sub(split_lamports) .saturating_sub(source_stake.delegation.stake) - < source_meta.rent_exempt_reserve + < source_rent_exempt_reserve { return Err(ProgramError::InsufficientFunds); } @@ -739,6 +741,7 @@ impl Processor { // converge let option_lockup_authority_info = next_account_info(account_info_iter).ok(); + let rent = &Rent::get()?; let clock = &Clock::get()?; let stake_history = &StakeHistorySysvar(clock.epoch); @@ -746,6 +749,8 @@ impl Processor { return Err(ProgramError::InvalidArgument); } + let source_rent_exempt_reserve = rent.minimum_balance(source_stake_account_info.data_len()); + // this is somewhat subtle. for Initialized and Stake, there is a real authority // but for Uninitialized, the source account is passed twice, and signed for let (signers, custodian) = @@ -770,7 +775,7 @@ impl Processor { stake.delegation.stake }; - let staked_and_reserve = checked_add(staked, meta.rent_exempt_reserve)?; + let staked_and_reserve = checked_add(staked, source_rent_exempt_reserve)?; (meta.lockup, staked_and_reserve, staked != 0) } Ok(StakeStateV2::Initialized(meta)) => { @@ -778,7 +783,7 @@ impl Processor { .check(&signers, StakeAuthorize::Withdrawer) .map_err(to_program_error)?; // stake accounts must have a balance >= rent_exempt_reserve - (meta.lockup, meta.rent_exempt_reserve, false) + (meta.lockup, source_rent_exempt_reserve, false) } Ok(StakeStateV2::Uninitialized) => { if !signers.contains(source_stake_account_info.key) { @@ -920,6 +925,8 @@ impl Processor { return Err(ProgramError::InvalidArgument); } + let source_lamports = source_stake_account_info.lamports(); + msg!("Checking if destination stake is mergeable"); let destination_merge_kind = MergeKind::get_if_mergeable( &get_stake_state(destination_stake_account_info)?, @@ -938,7 +945,7 @@ impl Processor { msg!("Checking if source stake is mergeable"); let source_merge_kind = MergeKind::get_if_mergeable( &get_stake_state(source_stake_account_info)?, - source_stake_account_info.lamports(), + source_lamports, clock, stake_history, )?; @@ -955,7 +962,7 @@ impl Processor { relocate_lamports( source_stake_account_info, destination_stake_account_info, - source_stake_account_info.lamports(), + source_lamports, )?; Ok(()) @@ -1219,7 +1226,7 @@ impl Processor { Ok(()) } - fn process_move_stake(accounts: &[AccountInfo], lamports: u64) -> ProgramResult { + fn process_move_stake(accounts: &[AccountInfo], move_amount: u64) -> ProgramResult { let account_info_iter = &mut accounts.iter(); // invariant @@ -1227,16 +1234,22 @@ impl Processor { let destination_stake_account_info = next_account_info(account_info_iter)?; let stake_authority_info = next_account_info(account_info_iter)?; + let rent = &Rent::get()?; + let (source_merge_kind, destination_merge_kind) = move_stake_or_lamports_shared_checks( source_stake_account_info, - lamports, + move_amount, destination_stake_account_info, stake_authority_info, )?; - // ensure source and destination are the right size for the current version of - // StakeState this a safeguard in case there is a new version of the - // struct that cannot fit into an old account + let source_rent_exempt_reserve = rent.minimum_balance(source_stake_account_info.data_len()); + + let destination_rent_exempt_reserve = + rent.minimum_balance(destination_stake_account_info.data_len()); + + // ensure source and destination are the right size for the current version of StakeState. + // this a safeguard in case there is a new version of the struct that cannot fit into an old account if source_stake_account_info.data_len() != StakeStateV2::size_of() || destination_stake_account_info.data_len() != StakeStateV2::size_of() { @@ -1251,20 +1264,18 @@ impl Processor { let minimum_delegation = crate::get_minimum_delegation(); let source_effective_stake = source_stake.delegation.stake; - // source cannot move more stake than it has, regardless of how many lamports it - // has + // source cannot move more stake than it has, regardless of how many lamports it has let source_final_stake = source_effective_stake - .checked_sub(lamports) + .checked_sub(move_amount) .ok_or(ProgramError::InvalidArgument)?; - // unless all stake is being moved, source must retain at least the minimum - // delegation + // unless all stake is being moved, source must retain at least the minimum delegation if source_final_stake != 0 && source_final_stake < minimum_delegation { return Err(ProgramError::InvalidArgument); } // destination must be fully active or fully inactive - let destination_meta = match destination_merge_kind { + match destination_merge_kind { MergeKind::FullyActive(destination_meta, mut destination_stake) => { // if active, destination must be delegated to the same vote account as source if source_stake.delegation.voter_pubkey != destination_stake.delegation.voter_pubkey @@ -1274,10 +1285,10 @@ impl Processor { let destination_effective_stake = destination_stake.delegation.stake; let destination_final_stake = destination_effective_stake - .checked_add(lamports) + .checked_add(move_amount) .ok_or(ProgramError::ArithmeticOverflow)?; - // ensure destination meets miniumum delegation + // ensure destination meets miniumum delegation. // since it is already active, this only really applies if the minimum is raised if destination_final_stake < minimum_delegation { return Err(ProgramError::InvalidArgument); @@ -1285,7 +1296,7 @@ impl Processor { merge_delegation_stake_and_credits_observed( &mut destination_stake, - lamports, + move_amount, source_stake.credits_observed, )?; @@ -1296,17 +1307,15 @@ impl Processor { destination_stake_account_info, &StakeStateV2::Stake(destination_meta, destination_stake, StakeFlags::empty()), )?; - - destination_meta } MergeKind::Inactive(destination_meta, _, _) => { // if destination is inactive, it must be given at least the minimum delegation - if lamports < minimum_delegation { + if move_amount < minimum_delegation { return Err(ProgramError::InvalidArgument); } let mut destination_stake = source_stake; - destination_stake.delegation.stake = lamports; + destination_stake.delegation.stake = move_amount; // StakeFlags::empty() is valid here because the only existing stake flag, // MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, is cleared when a stake @@ -1315,11 +1324,9 @@ impl Processor { destination_stake_account_info, &StakeStateV2::Stake(destination_meta, destination_stake, StakeFlags::empty()), )?; - - destination_meta } _ => return Err(ProgramError::InvalidAccountData), - }; + } if source_final_stake == 0 { set_stake_state( @@ -1341,13 +1348,13 @@ impl Processor { relocate_lamports( source_stake_account_info, destination_stake_account_info, - lamports, + move_amount, )?; // this should be impossible, but because we do all our math with delegations, // best to guard it - if source_stake_account_info.lamports() < source_meta.rent_exempt_reserve - || destination_stake_account_info.lamports() < destination_meta.rent_exempt_reserve + if source_stake_account_info.lamports() < source_rent_exempt_reserve + || destination_stake_account_info.lamports() < destination_rent_exempt_reserve { msg!("Delegation calculations violated lamport balance assumptions"); return Err(ProgramError::InvalidArgument); @@ -1356,7 +1363,7 @@ impl Processor { Ok(()) } - fn process_move_lamports(accounts: &[AccountInfo], lamports: u64) -> ProgramResult { + fn process_move_lamports(accounts: &[AccountInfo], move_amount: u64) -> ProgramResult { let account_info_iter = &mut accounts.iter(); // invariant @@ -1364,32 +1371,36 @@ impl Processor { let destination_stake_account_info = next_account_info(account_info_iter)?; let stake_authority_info = next_account_info(account_info_iter)?; + let rent = &Rent::get()?; + let (source_merge_kind, _) = move_stake_or_lamports_shared_checks( source_stake_account_info, - lamports, + move_amount, destination_stake_account_info, stake_authority_info, )?; + let source_rent_exempt_reserve = rent.minimum_balance(source_stake_account_info.data_len()); + let source_free_lamports = match source_merge_kind { - MergeKind::FullyActive(source_meta, source_stake) => source_stake_account_info + MergeKind::FullyActive(_, source_stake) => source_stake_account_info .lamports() .saturating_sub(source_stake.delegation.stake) - .saturating_sub(source_meta.rent_exempt_reserve), - MergeKind::Inactive(source_meta, source_lamports, _) => { - source_lamports.saturating_sub(source_meta.rent_exempt_reserve) + .saturating_sub(source_rent_exempt_reserve), + MergeKind::Inactive(_, source_lamports, _) => { + source_lamports.saturating_sub(source_rent_exempt_reserve) } _ => return Err(ProgramError::InvalidAccountData), }; - if lamports > source_free_lamports { + if move_amount > source_free_lamports { return Err(ProgramError::InvalidArgument); } relocate_lamports( source_stake_account_info, destination_stake_account_info, - lamports, + move_amount, )?; Ok(()) diff --git a/program/tests/interface.rs b/program/tests/interface.rs index b6e19527..17357033 100644 --- a/program/tests/interface.rs +++ b/program/tests/interface.rs @@ -3,6 +3,7 @@ use { arbitrary::{Arbitrary, Unstructured}, mollusk_svm::{result::Check, Mollusk}, + mollusk_svm_result::InstructionResult as MolluskResult, solana_account::{Account, ReadableAccount, WritableAccount}, solana_clock::Clock, solana_epoch_rewards::EpochRewards, @@ -10,7 +11,7 @@ use { solana_instruction::{AccountMeta, Instruction}, solana_native_token::LAMPORTS_PER_SOL, solana_pubkey::Pubkey, - solana_rent::Rent, + solana_rent::{Rent, DEFAULT_LAMPORTS_PER_BYTE_YEAR}, solana_sdk_ids::system_program, solana_stake_interface::{ instruction::{self, LockupArgs}, @@ -32,6 +33,7 @@ use { collections::{HashMap, HashSet}, sync::LazyLock, }, + test_case::test_case, }; // StakeInterface encapsulates every combination of instruction, account states, and input parameters @@ -105,13 +107,19 @@ fn assert_warmup_cooldown_rate() { assert_eq!(warmup_cooldown_rate(0, Some(0)), NEW_WARMUP_COOLDOWN_RATE); } -// hardcoded for convenience -const STAKE_RENT_EXEMPTION: u64 = 2_282_880; +// this mirrors the false const for `Meta.rent_exempt_reserve` in the stake program +// the stake program uses true `Rent` unconditionally but maintains this field for compatibility +// assert our consts in case cluster rent changes eventually lead to these values changing +const PSEUDO_RENT_EXEMPT_RESERVE: u64 = 2_282_880; #[test] -fn assert_stake_rent_exemption() { +fn assert_pseudo_stake_rent_exemption() { assert_eq!( Rent::default().minimum_balance(StakeStateV2::size_of()), - STAKE_RENT_EXEMPTION + PSEUDO_RENT_EXEMPT_RESERVE + ); + assert_eq!( + 1_000_000_000 / 100 * 365 / (1024 * 1024), + DEFAULT_LAMPORTS_PER_BYTE_YEAR, ); } @@ -141,9 +149,14 @@ struct Env { impl Env { // set up a test environment with valid stake history, two vote accounts, and two blank stake accounts fn init() -> Self { + Env::with_rent(Rent::default()) + } + + fn with_rent(rent: Rent) -> Self { // create a test environment at the execution epoch let mut base_accounts = HashMap::new(); let mut mollusk = Mollusk::new(&id(), "solana_stake_program"); + mollusk.sysvars.rent = rent; mollusk.warp_to_slot(EXECUTION_EPOCH * mollusk.sysvars.epoch_schedule.slots_per_epoch + 1); assert_eq!(mollusk.sysvars.clock.epoch, EXECUTION_EPOCH); @@ -167,7 +180,7 @@ impl Env { base_accounts.insert(PAYER, payer_account); // create two blank vote accounts - let vote_rent_exemption = Rent::default().minimum_balance(VoteStateV4::size_of()); + let vote_rent_exemption = mollusk.sysvars.rent.minimum_balance(VoteStateV4::size_of()); let vote_state_versions = VoteStateVersions::new_v4(VoteStateV4::default()); let vote_data = bincode::serialize(&vote_state_versions).unwrap(); let vote_account = Account::create( @@ -200,7 +213,10 @@ impl Env { // create two blank stake accounts let stake_account = Account::create( - STAKE_RENT_EXEMPTION, + mollusk + .sysvars + .rent + .minimum_balance(StakeStateV2::size_of()), vec![0; StakeStateV2::size_of()], id(), false, @@ -278,10 +294,10 @@ impl Env { } // immutable process that should succeed - fn process_success(&self, instruction: &Instruction) { + fn process_success(&self, instruction: &Instruction) -> MolluskResult { let accounts = self.resolve_accounts(&instruction.accounts); self.mollusk - .process_and_validate_instruction(instruction, &accounts, &[Check::success()]); + .process_and_validate_instruction(instruction, &accounts, &[Check::success()]) } // immutable process that should fail @@ -291,9 +307,15 @@ impl Env { assert!(result.program_result.is_err()); } + // reset Env back to its setup state for reuse fn reset(&mut self) { self.override_accounts.clear() } + + // calculate rent exemption via our configured Rent + fn minimum_balance(&self, size: usize) -> u64 { + self.mollusk.sysvars.rent.minimum_balance(size) + } } // NOTE we skip: @@ -388,6 +410,7 @@ impl StakeInterface { // creates an instruction with the given combination of settings that is guaranteed to succeed fn to_instruction(self, env: &mut Env) -> Instruction { + let rent_exempt_reserve = env.minimum_balance(StakeStateV2::size_of()); let minimum_delegation = get_minimum_delegation(); match self { @@ -542,7 +565,7 @@ impl StakeInterface { } => { let delegated_stake = minimum_delegation * 2; let split_amount = if full_split { - delegated_stake + STAKE_RENT_EXEMPTION + delegated_stake + rent_exempt_reserve } else { delegated_stake / 2 }; @@ -713,7 +736,7 @@ impl StakeInterface { ); let withdraw_amount = if full_withdraw && source_status != StakeStatus::Active { - free_lamports + minimum_delegation + STAKE_RENT_EXEMPTION + free_lamports + minimum_delegation + rent_exempt_reserve } else { free_lamports }; @@ -920,7 +943,7 @@ fn fully_configurable_stake( }; let meta = Meta { - rent_exempt_reserve: STAKE_RENT_EXEMPTION, + rent_exempt_reserve: PSEUDO_RENT_EXEMPT_RESERVE, authorized, lockup, }; @@ -1179,6 +1202,47 @@ fn test_no_signer_bypass_new_interface() { } } +// cluster-wide rent will be lowered in the future +// test that various different rent values do not interfere with stake program operations +// also test that the stake program preserves the legacy `Meta.rent_exempt_reserve` value +// we dont need to parametrize our failure tests over rent because none care about lamports +#[test_case(DEFAULT_LAMPORTS_PER_BYTE_YEAR / 2; "half_rent")] +#[test_case(DEFAULT_LAMPORTS_PER_BYTE_YEAR / 10; "tenth_rent")] +#[test_case(DEFAULT_LAMPORTS_PER_BYTE_YEAR * 2; "twice_rent")] +fn test_all_success_non_default_rent(lamports_per_byte_year: u64) { + let rent = Rent { + lamports_per_byte_year, + ..Rent::default() + }; + + let mut env = Env::with_rent(rent); + + for declaration in &*INSTRUCTION_DECLARATIONS { + let instruction = declaration.to_instruction(&mut env); + let result = env.process_success(&instruction); + + for (pubkey, account) in result.resulting_accounts.into_iter() { + if pubkey != STAKE_ACCOUNT_BLACK && pubkey != STAKE_ACCOUNT_WHITE { + continue; + } + + if account.data().is_empty() { + continue; + } + + match account.deserialize_data::().unwrap() { + StakeStateV2::Initialized(meta) | StakeStateV2::Stake(meta, _, _) => { + assert_eq!(meta.rent_exempt_reserve, PSEUDO_RENT_EXEMPT_RESERVE) + } + StakeStateV2::Uninitialized => (), + StakeStateV2::RewardsPool => unreachable!(), + } + } + + env.reset(); + } +} + // this prints ballpark compute unit costs suitable for insertion in README.md // run with `cargo test --test interface show_compute_usage -- --nocapture --ignored` #[test] diff --git a/program/tests/stake_instruction.rs b/program/tests/stake_instruction.rs index e8f2b5c1..03e89a3d 100644 --- a/program/tests/stake_instruction.rs +++ b/program/tests/stake_instruction.rs @@ -65,6 +65,10 @@ fn create_default_vote_account() -> AccountSharedData { .unwrap() } +fn default_stake_rent() -> u64 { + Rent::default().minimum_balance(StakeStateV2::size_of()) +} + fn invalid_stake_state_pubkey() -> Pubkey { Pubkey::from_str("BadStake11111111111111111111111111111111111").unwrap() } @@ -1571,9 +1575,8 @@ fn test_authorize_delegated_stake() { let authority_address = solana_pubkey::new_rand(); let stake_address = solana_pubkey::new_rand(); let minimum_delegation = crate::get_minimum_delegation(); - let stake_lamports = minimum_delegation; let stake_account = AccountSharedData::new_data_with_space( - stake_lamports, + minimum_delegation + default_stake_rent(), &StakeStateV2::Initialized(Meta::auto(&stake_address)), StakeStateV2::size_of(), &id(), @@ -1780,10 +1783,10 @@ fn test_stake_delegate() { .set_state(&VoteStateVersions::new_v4(vote_state)) .unwrap(); let minimum_delegation = crate::get_minimum_delegation(); - let stake_lamports = minimum_delegation; + let delegated_stake = minimum_delegation; let stake_address = solana_pubkey::new_rand(); let mut stake_account = AccountSharedData::new_data_with_space( - stake_lamports, + delegated_stake + default_stake_rent(), &StakeStateV2::Initialized(Meta { authorized: Authorized { staker: stake_address, @@ -1869,7 +1872,7 @@ fn test_stake_delegate() { Stake { delegation: Delegation { voter_pubkey: vote_address, - stake: stake_lamports, + stake: delegated_stake, activation_epoch: clock.epoch, deactivation_epoch: u64::MAX, ..Delegation::default() @@ -1968,7 +1971,7 @@ fn test_stake_delegate() { Stake { delegation: Delegation { voter_pubkey: vote_address_2, - stake: stake_lamports, + stake: delegated_stake, activation_epoch: clock.epoch, deactivation_epoch: u64::MAX, ..Delegation::default() @@ -2218,10 +2221,11 @@ fn test_split() { }; let stake_address = solana_pubkey::new_rand(); let minimum_delegation = crate::get_minimum_delegation(); - let stake_lamports = minimum_delegation * 2; + let delegated_stake = minimum_delegation * 2; + let total_lamports = delegated_stake + default_stake_rent(); let split_to_address = solana_pubkey::new_rand(); let split_to_account = AccountSharedData::new_data_with_space( - 0, + default_stake_rent(), &StakeStateV2::Uninitialized, StakeStateV2::size_of(), &id(), @@ -2232,10 +2236,7 @@ fn test_split() { (split_to_address, split_to_account.clone()), ( rent::id(), - create_account_shared_data_for_test(&Rent { - lamports_per_byte_year: 0, - ..Rent::default() - }), + create_account_shared_data_for_test(&Rent::default()), ), ( StakeHistory::id(), @@ -2260,12 +2261,23 @@ fn test_split() { }, ]; - for state in [ - StakeStateV2::Initialized(Meta::auto(&stake_address)), - just_stake(Meta::auto(&stake_address), stake_lamports), + let meta = Meta { + rent_exempt_reserve: default_stake_rent(), + ..Meta::auto(&stake_address) + }; + + for (state, expected_error) in [ + ( + StakeStateV2::Initialized(meta), + ProgramError::InsufficientFunds, + ), + ( + just_stake(meta, delegated_stake), + StakeError::InsufficientDelegation.into(), + ), ] { let stake_account = AccountSharedData::new_data_with_space( - stake_lamports, + total_lamports, &state, StakeStateV2::size_of(), &id(), @@ -2281,16 +2293,16 @@ fn test_split() { // should fail, split more than available process_instruction( &mollusk, - &serialize(&StakeInstruction::Split(stake_lamports + 1)).unwrap(), + &serialize(&StakeInstruction::Split(delegated_stake + 1)).unwrap(), transaction_accounts.clone(), instruction_accounts.clone(), - Err(ProgramError::InsufficientFunds), + Err(expected_error), ); // should pass let accounts = process_instruction( &mollusk, - &serialize(&StakeInstruction::Split(stake_lamports / 2)).unwrap(), + &serialize(&StakeInstruction::Split(delegated_stake / 2)).unwrap(), transaction_accounts.clone(), instruction_accounts.clone(), Ok(()), @@ -2298,7 +2310,7 @@ fn test_split() { // no lamport leakage assert_eq!( accounts[0].lamports() + accounts[1].lamports(), - stake_lamports + delegated_stake + default_stake_rent() * 2, ); // no deactivated stake @@ -2314,7 +2326,7 @@ fn test_split() { } StakeStateV2::Stake(_meta, _stake, _) => { let stake_0 = from(&accounts[0]).unwrap().stake(); - assert_eq!(stake_0.unwrap().delegation.stake, stake_lamports / 2); + assert_eq!(stake_0.unwrap().delegation.stake, delegated_stake / 2); } _ => unreachable!(), } @@ -2322,7 +2334,7 @@ fn test_split() { // should fail, fake owner of destination let split_to_account = AccountSharedData::new_data_with_space( - 0, + default_stake_rent(), &StakeStateV2::Uninitialized, StakeStateV2::size_of(), &solana_pubkey::new_rand(), @@ -2331,7 +2343,7 @@ fn test_split() { transaction_accounts[1] = (split_to_address, split_to_account); process_instruction( &mollusk, - &serialize(&StakeInstruction::Split(stake_lamports / 2)).unwrap(), + &serialize(&StakeInstruction::Split(delegated_stake / 2)).unwrap(), transaction_accounts, instruction_accounts, Err(ProgramError::InvalidAccountOwner), @@ -2647,8 +2659,8 @@ fn test_withdraw_stake_before_warmup() { let recipient_address = solana_pubkey::new_rand(); let stake_address = solana_pubkey::new_rand(); let minimum_delegation = crate::get_minimum_delegation(); - let stake_lamports = minimum_delegation; - let total_lamports = stake_lamports + 33; + let delegated_stake = minimum_delegation; + let total_lamports = delegated_stake + default_stake_rent() + 33; let stake_account = AccountSharedData::new_data_with_space( total_lamports, &StakeStateV2::Initialized(Meta::auto(&stake_address)), @@ -2762,7 +2774,7 @@ fn test_withdraw_stake_before_warmup() { process_instruction( &mollusk, &serialize(&StakeInstruction::Withdraw( - total_lamports - stake_lamports + 1, + total_lamports - delegated_stake + 1, )) .unwrap(), transaction_accounts, @@ -2999,9 +3011,8 @@ fn test_deactivate() { let stake_address = solana_pubkey::new_rand(); let minimum_delegation = crate::get_minimum_delegation(); - let stake_lamports = minimum_delegation; let stake_account = AccountSharedData::new_data_with_space( - stake_lamports, + minimum_delegation + default_stake_rent(), &StakeStateV2::Initialized(Meta::auto(&stake_address)), StakeStateV2::size_of(), &id(), @@ -4045,8 +4056,7 @@ fn test_withdraw_minimum_stake_delegation() { let mollusk = mollusk_bpf(); let minimum_delegation = crate::get_minimum_delegation(); - let rent = Rent::default(); - let rent_exempt_reserve = rent.minimum_balance(StakeStateV2::size_of()); + let rent_exempt_reserve = default_stake_rent(); let stake_address = solana_pubkey::new_rand(); let meta = Meta { rent_exempt_reserve, @@ -4115,7 +4125,7 @@ fn test_withdraw_minimum_stake_delegation() { ), ( rent::id(), - create_account_shared_data_for_test(&Rent::free()), + create_account_shared_data_for_test(&Rent::default()), ), ( StakeHistory::id(), @@ -7079,7 +7089,7 @@ fn setup_delegate_test_with_vote_account( let minimum_delegation = crate::get_minimum_delegation(); let stake_account = AccountSharedData::new_data_with_space( - minimum_delegation, + minimum_delegation + default_stake_rent(), &StakeStateV2::Initialized(Meta { authorized: Authorized { staker: stake_address,