diff --git a/program/src/error.rs b/program/src/error.rs index 980dc00b..7555ea09 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -54,8 +54,9 @@ pub enum SinglePoolError { // 10 /// Not enough stake to cover the provided quantity of pool tokens. - /// (Generally this should not happen absent user error, but may if the - /// minimum delegation increases beyond 1 sol.) + /// This typically means the value exists in the pool as activating stake, + /// and an epoch is required for it to become available. Otherwise, it means + /// active stake in the on-ramp must be moved via `ReplenishPool`. #[error("WithdrawalTooLarge")] WithdrawalTooLarge, /// Required signature is missing. @@ -105,6 +106,10 @@ pub enum SinglePoolError { /// is in an exceptional state, or because the on-ramp account should be refreshed. #[error("ReplenishRequired")] ReplenishRequired, + /// Withdrawal would render the pool stake account impossible to redelegate. + /// This can only occur if the Stake Program minimum delegation increases above 1 sol. + #[error("WithdrawalViolatesPoolRequirements")] + WithdrawalViolatesPoolRequirements, } impl From for ProgramError { fn from(e: SinglePoolError) -> Self { @@ -137,8 +142,9 @@ impl ToStr for SinglePoolError { "Error: Not enough pool tokens provided to withdraw stake worth one lamport.", SinglePoolError::WithdrawalTooLarge => "Error: Not enough stake to cover the provided quantity of pool tokens. \ - (Generally this should not happen absent user error, but may if the minimum delegation increases \ - beyond 1 sol.)", + This typically means the value exists in the pool as activating stake, \ + and an epoch is required for it to become available. Otherwise, it means \ + active stake in the onramp must be moved via `ReplenishPool`.", SinglePoolError::SignatureMissing => "Error: Required signature is missing.", SinglePoolError::WrongStakeState => "Error: Stake account is not in the state expected by the program.", SinglePoolError::ArithmeticOverflow => "Error: Unsigned subtraction crossed the zero.", @@ -157,11 +163,14 @@ impl ToStr for SinglePoolError { SinglePoolError::InvalidPoolOnRampAccount => "Error: Provided pool onramp account does not match address derived from the pool account.", SinglePoolError::OnRampDoesntExist => - "The onramp account for this pool does not exist; you must call `InitializePoolOnRamp` \ + "Error: The onramp account for this pool does not exist; you must call `InitializePoolOnRamp` \ before you can perform this operation.", SinglePoolError::ReplenishRequired => "Error: The present operation requires a `ReplenishPool` call, either because the pool stake account \ is in an exceptional state, or because the on-ramp account should be refreshed.", + SinglePoolError::WithdrawalViolatesPoolRequirements => + "Error: Withdrawal would render the pool stake account impossible to redelegate. \ + This can only occur if the Stake Program minimum delegation increases above 1 sol.", } } } diff --git a/program/src/processor.rs b/program/src/processor.rs index cf6bf02a..18e46e11 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -40,33 +40,49 @@ use { spl_token_interface::{self as spl_token, state::Mint}, }; -/// Calculate pool tokens to mint, given outstanding token supply, pool active -/// stake, and deposit active stake +/// Determine the canonical value of the pool from its staked and stake-able lamports +fn pool_net_asset_value( + pool_stake_info: &AccountInfo, + pool_onramp_info: &AccountInfo, + rent: &Rent, +) -> u64 { + // these numbers should typically be equal, but might differ during StakeState upgrades + let pool_rent_exempt_reserve = rent.minimum_balance(pool_stake_info.data_len()); + let onramp_rent_exempt_reserve = rent.minimum_balance(pool_onramp_info.data_len()); + + // NEV is all lamports in both accounts less rent + pool_stake_info + .lamports() + .saturating_add(pool_onramp_info.lamports()) + .saturating_sub(pool_rent_exempt_reserve) + .saturating_sub(onramp_rent_exempt_reserve) +} + +/// Calculate pool tokens to mint, given outstanding token supply, pool NEV, and deposit amount fn calculate_deposit_amount( pre_token_supply: u64, - pre_pool_stake: u64, - user_stake_to_deposit: u64, + pre_pool_nev: u64, + user_deposit_amount: u64, ) -> Option { - if pre_pool_stake == 0 || pre_token_supply == 0 { - Some(user_stake_to_deposit) + if pre_pool_nev == 0 || pre_token_supply == 0 { + Some(user_deposit_amount) } else { u64::try_from( - (user_stake_to_deposit as u128) + (user_deposit_amount as u128) .checked_mul(pre_token_supply as u128)? - .checked_div(pre_pool_stake as u128)?, + .checked_div(pre_pool_nev as u128)?, ) .ok() } } -/// Calculate pool stake to return, given outstanding token supply, pool active -/// stake, and tokens to redeem +/// Calculate pool value to return, given outstanding token supply, pool NEV, and tokens to redeem fn calculate_withdraw_amount( pre_token_supply: u64, - pre_pool_stake: u64, + pre_pool_nev: u64, user_tokens_to_burn: u64, ) -> Option { - let numerator = (user_tokens_to_burn as u128).checked_mul(pre_pool_stake as u128)?; + let numerator = (user_tokens_to_burn as u128).checked_mul(pre_pool_nev as u128)?; let denominator = pre_token_supply as u128; if numerator < denominator || denominator == 0 { Some(0) @@ -774,8 +790,8 @@ impl Processor { let stake_config_info = next_account_info(account_info_iter)?; let stake_program_info = next_account_info(account_info_iter)?; + let rent = Rent::get()?; let stake_history = &StakeHistorySysvar(clock.epoch); - let minimum_delegation = stake::tools::get_minimum_delegation()?; check_vote_account(vote_account_info)?; check_pool_address(program_id, vote_account_info.key, pool_info.key)?; @@ -791,8 +807,9 @@ impl Processor { )?; check_stake_program(stake_program_info.key)?; + let minimum_delegation = stake::tools::get_minimum_delegation()?; + // we expect these numbers to be equal but get them separately in case of future changes - let rent = Rent::get()?; let pool_rent_exempt_reserve = rent.minimum_balance(pool_stake_info.data_len()); let onramp_rent_exempt_reserve = rent.minimum_balance(pool_onramp_info.data_len()); @@ -970,6 +987,7 @@ impl Processor { let token_program_info = next_account_info(account_info_iter)?; let stake_program_info = next_account_info(account_info_iter)?; + let rent = &Rent::get()?; let stake_history = &StakeHistorySysvar(clock.epoch); SinglePool::from_account_info(pool_info, program_id)?; @@ -998,9 +1016,8 @@ impl Processor { return Err(SinglePoolError::InvalidPoolStakeAccountUsage.into()); } - let (_, pool_stake_state) = get_stake_state(pool_stake_info)?; - - let (pool_is_active, pool_is_activating) = { + let (pre_pool_stake, pool_is_active, pool_is_activating) = { + let (_, pool_stake_state) = get_stake_state(pool_stake_info)?; let pool_stake_status = pool_stake_state .delegation .stake_activating_and_deactivating( @@ -1010,6 +1027,7 @@ impl Processor { ); ( + pool_stake_state.delegation.stake, is_stake_fully_active(&pool_stake_status), is_stake_newly_activating(&pool_stake_status), ) @@ -1030,10 +1048,10 @@ impl Processor { unreachable!(); }; - let pre_pool_stake = pool_stake_state.delegation.stake; - let pre_pool_lamports = pool_stake_info.lamports(); - msg!("Available stake pre merge {}", pre_pool_stake); + // tokens for deposit are determined off the total stakeable value of both pool-owned accounts + let pre_total_nev = pool_net_asset_value(pool_stake_info, pool_onramp_info, rent); + let pre_user_lamports = user_stake_info.lamports(); let (user_stake_meta, user_stake_status) = match deserialize_stake(user_stake_info) { Ok(StakeStateV2::Stake(meta, stake, _)) => ( meta, @@ -1078,21 +1096,15 @@ impl Processor { stake_history_info.clone(), )?; - let (_, pool_stake_state) = get_stake_state(pool_stake_info)?; - let post_pool_stake = pool_stake_state.delegation.stake; - let post_pool_lamports = pool_stake_info.lamports(); - msg!("Available stake post merge {}", post_pool_stake); - - // stake lamports added, as a stake difference - let stake_added = post_pool_stake + // determine new stake lamports added by merge + let post_pool_stake = get_stake_amount(pool_stake_info)?; + let new_stake_added = post_pool_stake .checked_sub(pre_pool_stake) .ok_or(SinglePoolError::ArithmeticOverflow)?; - // if there were excess lamports in the user-provided account, we return them - // this includes their rent-exempt reserve if the pool is fully active - let user_excess_lamports = post_pool_lamports - .checked_sub(pre_pool_lamports) - .and_then(|amount| amount.checked_sub(stake_added)) + // return user lamports that were not added to stake + let user_excess_lamports = pre_user_lamports + .checked_sub(new_stake_added) .ok_or(SinglePoolError::ArithmeticOverflow)?; // sanity check: the user stake account is empty @@ -1101,8 +1113,9 @@ impl Processor { } // deposit amount is determined off stake added because we return excess lamports - let new_pool_tokens = calculate_deposit_amount(token_supply, pre_pool_stake, stake_added) - .ok_or(SinglePoolError::UnexpectedMathError)?; + let new_pool_tokens = + calculate_deposit_amount(token_supply, pre_total_nev, new_stake_added) + .ok_or(SinglePoolError::UnexpectedMathError)?; if new_pool_tokens == 0 { return Err(SinglePoolError::DepositTooSmall.into()); @@ -1152,9 +1165,13 @@ impl Processor { let user_stake_info = next_account_info(account_info_iter)?; let user_token_account_info = next_account_info(account_info_iter)?; let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; let token_program_info = next_account_info(account_info_iter)?; let stake_program_info = next_account_info(account_info_iter)?; + let rent = &Rent::get()?; + let stake_history = &StakeHistorySysvar(clock.epoch); + SinglePool::from_account_info(pool_info, program_id)?; check_pool_stake_address(program_id, pool_info.key, pool_stake_info.key)?; @@ -1181,26 +1198,73 @@ impl Processor { return Err(SinglePoolError::InvalidPoolStakeAccountUsage.into()); } - // we deliberately do NOT validate the activation status of the pool account. - // neither snow nor rain nor warmup/cooldown nor validator delinquency prevents a user withdrawal - // - // NOTE this is fine for stake v4 but subtly wrong for stake v5 *if* the pool account was deactivated. - // stake v5 declines to (meaninglessly) adjust delegations of deactivated sources. - // this will (again) be correct with #581, which shifts to NEV accounting on lamports rather than stake. - // we should plan another SVSP release before stake v5 activation - let pre_pool_stake = get_stake_amount(pool_stake_info)?; - msg!("Available stake pre split {}", pre_pool_stake); - - // withdraw amount is determined off stake just like deposit amount - let withdraw_stake = calculate_withdraw_amount(token_supply, pre_pool_stake, token_amount) - .ok_or(SinglePoolError::UnexpectedMathError)?; - - if withdraw_stake == 0 { + if token_amount == 0 { return Err(SinglePoolError::WithdrawalTooSmall.into()); } - // the second case should never be true, but its best to be sure - if withdraw_stake > pre_pool_stake || withdraw_stake == pool_stake_info.lamports() { + let minimum_delegation = stake::tools::get_minimum_delegation()?; + + // tokens for withdraw are determined off the total stakeable value of both pool-owned accounts + let pre_total_nev = pool_net_asset_value(pool_stake_info, pool_onramp_info, rent); + + // note we deliberately do NOT validate the activation status of the pool account. + // neither warmup/cooldown nor validator delinquency prevent a user withdrawal. + // however, because we calculate NEV from all lamports in both pool accounts, + // but can only split stake from the main account (unless inactive), we must determine whether this is possible + let (withdrawable_value, pool_is_fully_inactive) = { + let (_, pool_stake_state) = get_stake_state(pool_stake_info)?; + let pool_stake_status = pool_stake_state + .delegation + .stake_activating_and_deactivating( + clock.epoch, + stake_history, + PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, + ); + + // if fully inactive, we split on lamports; otherwise, on all delegation. + // the stake program works off delegation in this way *even* for a partially deactivated stake + if pool_stake_status == StakeActivationStatus::default() { + ( + pool_stake_info + .lamports() + .saturating_sub(rent.minimum_balance(pool_stake_info.data_len())), + true, + ) + } else { + (pool_stake_state.delegation.stake, false) + } + }; + + // withdraw amount is determined off pool NEV just like deposit amount + let stake_to_withdraw = + calculate_withdraw_amount(token_supply, pre_total_nev, token_amount) + .ok_or(SinglePoolError::UnexpectedMathError)?; + + // self-explanatory. we catch 0 deposit above so we only hit this if we rounded to 0 + if stake_to_withdraw == 0 { + return Err(SinglePoolError::WithdrawalTooSmall.into()); + } + + // the pool must *always* meet minimum delegation, even if it is inactive. + // this error is currently impossible to hit and exists to protect pools if minimum delegation rises above 1sol + if withdrawable_value.saturating_sub(stake_to_withdraw) < minimum_delegation { + return Err(SinglePoolError::WithdrawalViolatesPoolRequirements.into()); + } + + // this is impossible but we guard explicitly because it would put the pool in an unrecoverable state + if stake_to_withdraw == pool_stake_info.lamports() { + return Err(SinglePoolError::WithdrawalViolatesPoolRequirements.into()); + } + + // if the destination would be in any non-inactive state it must meet minimum delegation + if !pool_is_fully_inactive && stake_to_withdraw < minimum_delegation { + return Err(SinglePoolError::WithdrawalTooSmall.into()); + } + + // if we do not have enough value to service this withdrawal, the user must wait a `ReplenishPool` cycle. + // this does *not* mean the value isnt in the pool, merely that it is not duly splittable. + // this check should always come last to avoid returning it if the withdrawal is actually invalid + if stake_to_withdraw > withdrawable_value { return Err(SinglePoolError::WithdrawalTooLarge.into()); } @@ -1221,7 +1285,7 @@ impl Processor { pool_stake_info.clone(), pool_stake_authority_info.clone(), stake_authority_bump_seed, - withdraw_stake, + stake_to_withdraw, user_stake_info.clone(), )?; @@ -1235,9 +1299,6 @@ impl Processor { clock_info.clone(), )?; - let post_pool_stake = get_stake_amount(pool_stake_info)?; - msg!("Available stake post split {}", post_pool_stake); - Ok(()) } @@ -1543,7 +1604,7 @@ mod tests { test_case::test_case, }; - // approximately 6%/yr assuking 146 epochs + // approximately 6%/yr assuming 146 epochs const INFLATION_BASE_RATE: f64 = 0.0004; #[derive(Clone, Debug, Default)] diff --git a/program/tests/deposit.rs b/program/tests/deposit.rs index 2764c0f5..c295172a 100644 --- a/program/tests/deposit.rs +++ b/program/tests/deposit.rs @@ -27,14 +27,12 @@ use spl_single_pool::find_default_deposit_account_address; [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], [false, true], [0, 100_000], - [0, 100_000], [false, true] )] #[tokio::test] async fn success( stake_version: StakeProgramVersion, activate: bool, - pool_extra_lamports: u64, alice_extra_lamports: u64, prior_deposit: bool, ) { @@ -86,17 +84,6 @@ async fn success( .unwrap(); } - if pool_extra_lamports > 0 { - transfer( - &mut context.banks_client, - &context.payer, - &context.last_blockhash, - &accounts.stake_account, - pool_extra_lamports, - ) - .await; - } - if alice_extra_lamports > 0 { let transaction = Transaction::new_signed_with_payer( &[system_instruction::transfer( @@ -175,11 +162,11 @@ async fn success( // entire active stake, or all lamports, have moved to pool assert_eq!(pool_stake_before + expected_deposit, pool_stake_after); - // pool only gained stake, pool kept any extra lamports it had + // pool gained appropriate stake assert_eq!(pool_lamports_after, pool_lamports_before + expected_deposit); assert_eq!( pool_lamports_after, - pool_stake_before + expected_deposit + rent_exempt_reserve + pool_extra_lamports, + pool_stake_before + expected_deposit + rent_exempt_reserve, ); // alice got her rent and extra back if active, or just extra back otherwise @@ -720,3 +707,120 @@ async fn all_activation_states( check_error(e, SinglePoolError::WrongStakeState); } } + +#[test_matrix( + [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], + [false, true] +)] +#[tokio::test] +async fn success_additional_value(stake_version: StakeProgramVersion, activate: bool) { + let Some(program_test) = program_test(stake_version) else { + return; + }; + let mut context = program_test.start_with_context().await; + + let stake_amount = get_minimum_pool_balance( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + let accounts = SinglePoolAccounts::default(); + accounts + .initialize_for_deposit(&mut context, stake_amount, None) + .await; + + let stake_rent = context + .banks_client + .get_rent() + .await + .unwrap() + .minimum_balance(StakeStateV2::size_of()); + + // add onramp stake + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.onramp_account, + stake_amount, + ) + .await; + replenish(&mut context, &accounts.vote_account.pubkey()).await; + + let expected_additional_tokens = if activate { + advance_epoch(&mut context).await; + 0 + } else { + stake_rent / 4 + }; + + // add pool lamports + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.stake_account, + stake_amount, + ) + .await; + + // add onramp lamports + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.onramp_account, + stake_amount, + ) + .await; + + // stake_amount == minimum_pool_balance + // pool starts with SA and we add another 3SA so a SA deposit yields SA/4 tokens + // (plus rent/4 if pool is activating) + // since there are SA notional tokens backed by 2SA stake and 2A extra lamports + + let instructions = instruction::deposit( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + &accounts.alice.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // got the tokens we expect + assert_eq!( + get_token_balance(&mut context.banks_client, &accounts.alice_token).await, + stake_amount / 4 + expected_additional_tokens + ); + + // pool lamports have not been removed + let pool_lamports = get_account(&mut context.banks_client, &accounts.stake_account) + .await + .lamports; + + assert_eq!( + pool_lamports, + stake_amount * 3 + if activate { stake_rent } else { stake_rent * 2 }, + ); + + let onramp_lamports = get_account(&mut context.banks_client, &accounts.onramp_account) + .await + .lamports; + + assert_eq!(onramp_lamports, stake_amount * 2 + stake_rent); +} diff --git a/program/tests/helpers/mod.rs b/program/tests/helpers/mod.rs index 7875f434..3450954e 100644 --- a/program/tests/helpers/mod.rs +++ b/program/tests/helpers/mod.rs @@ -457,6 +457,24 @@ pub async fn transfer( banks_client.process_transaction(transaction).await.unwrap(); } +pub async fn replenish(context: &mut ProgramTestContext, vote_account: &Pubkey) { + let instruction = instruction::replenish_pool(&id(), vote_account); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + refresh_blockhash(context).await; +} + pub fn check_error(got: BanksClientError, expected: T) where ProgramError: TryFrom, diff --git a/program/tests/helpers/stake.rs b/program/tests/helpers/stake.rs index 6645539b..9189a332 100644 --- a/program/tests/helpers/stake.rs +++ b/program/tests/helpers/stake.rs @@ -4,15 +4,18 @@ use { crate::get_account, bincode::deserialize, + solana_account::AccountSharedData, solana_hash::Hash, solana_keypair::Keypair, solana_native_token::LAMPORTS_PER_SOL, solana_program_test::BanksClient, + solana_program_test::ProgramTestContext, solana_pubkey::Pubkey, solana_signer::Signer, solana_stake_interface::{ instruction as stake_instruction, program as stake_program, - state::{Authorized, Lockup, Meta, Stake, StakeStateV2}, + stake_flags::StakeFlags, + state::{Authorized, Delegation, Lockup, Meta, Stake, StakeStateV2}, }, solana_system_interface::instruction as system_instruction, solana_transaction::Transaction, @@ -20,6 +23,7 @@ use { }; pub const TEST_STAKE_AMOUNT: u64 = 10_000_000_000; // 10 sol +pub const MANGLED_DELEGATION: u64 = 12345; pub async fn get_stake_account( banks_client: &mut BanksClient, @@ -145,3 +149,31 @@ pub async fn delegate_stake_account( transaction.sign(&[payer, authorized], *recent_blockhash); banks_client.process_transaction(transaction).await.unwrap(); } + +pub async fn force_deactivate_stake_account(context: &mut ProgramTestContext, pubkey: &Pubkey) { + let (meta, stake, _) = get_stake_account(&mut context.banks_client, pubkey).await; + let delegation = Delegation { + activation_epoch: 0, + deactivation_epoch: 0, + // break anything which erroneously uses this in calculations without redelegating + stake: MANGLED_DELEGATION, + ..stake.unwrap().delegation + }; + let mut account_data = vec![0; std::mem::size_of::()]; + bincode::serialize_into( + &mut account_data[..], + &StakeStateV2::Stake( + meta, + Stake { + delegation, + ..stake.unwrap() + }, + StakeFlags::empty(), + ), + ) + .unwrap(); + + let mut stake_account = get_account(&mut context.banks_client, pubkey).await; + stake_account.data = account_data; + context.set_account(pubkey, &AccountSharedData::from(stake_account)); +} diff --git a/program/tests/replenish.rs b/program/tests/replenish.rs index d8fed02e..8652f43b 100644 --- a/program/tests/replenish.rs +++ b/program/tests/replenish.rs @@ -4,40 +4,17 @@ mod helpers; use { helpers::*, - solana_account::AccountSharedData, solana_clock::Clock, solana_program_test::*, - solana_pubkey::Pubkey, solana_signer::Signer, solana_stake_interface::{ - instruction as stake_instruction, - stake_flags::StakeFlags, - stake_history::StakeHistory, - state::{Delegation, Stake, StakeStateV2}, + instruction as stake_instruction, stake_history::StakeHistory, state::StakeStateV2, }, solana_transaction::Transaction, spl_single_pool::{error::SinglePoolError, id, instruction}, test_case::test_matrix, }; -async fn replenish(context: &mut ProgramTestContext, vote_account: &Pubkey) { - let instruction = instruction::replenish_pool(&id(), vote_account); - let transaction = Transaction::new_signed_with_payer( - &[instruction], - Some(&context.payer.pubkey()), - &[&context.payer], - context.last_blockhash, - ); - - context - .banks_client - .process_transaction(transaction) - .await - .unwrap(); - - refresh_blockhash(context).await; -} - #[test_matrix( [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], [false, true], @@ -77,36 +54,9 @@ async fn reactivate_success( advance_epoch(&mut context).await; - // deactivate the pool stake account if reactivate_pool { - let (meta, stake, _) = - get_stake_account(&mut context.banks_client, &accounts.stake_account).await; - let delegation = Delegation { - activation_epoch: 0, - deactivation_epoch: 0, - ..stake.unwrap().delegation - }; - let mut account_data = vec![0; std::mem::size_of::()]; - bincode::serialize_into( - &mut account_data[..], - &StakeStateV2::Stake( - meta, - Stake { - delegation, - ..stake.unwrap() - }, - StakeFlags::empty(), - ), - ) - .unwrap(); - - let mut stake_account = - get_account(&mut context.banks_client, &accounts.stake_account).await; - stake_account.data = account_data; - context.set_account( - &accounts.stake_account, - &AccountSharedData::from(stake_account), - ); + // deactivate the pool stake account + force_deactivate_stake_account(&mut context, &accounts.stake_account).await; // active deposit into deactivated pool fails let instructions = instruction::deposit( diff --git a/program/tests/withdraw.rs b/program/tests/withdraw.rs index 9435b814..3c2c9c68 100644 --- a/program/tests/withdraw.rs +++ b/program/tests/withdraw.rs @@ -3,24 +3,26 @@ mod helpers; use { helpers::*, + solana_program_pack::Pack, solana_program_test::*, solana_signer::Signer, solana_transaction::Transaction, spl_single_pool::{error::SinglePoolError, id, instruction}, + spl_token_interface::state::Mint, test_case::{test_case, test_matrix}, }; #[test_matrix( [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], [false, true], - [0, 100_000], + [0, 1], [false, true] )] #[tokio::test] async fn success( stake_version: StakeProgramVersion, activate: bool, - extra_lamports_in_destination: u64, + extra_lamports_in_pool: u64, other_user_deposits: bool, ) { let Some(program_test) = program_test(stake_version) else { @@ -52,13 +54,13 @@ async fn success( .await .lamports; - if extra_lamports_in_destination > 0 { + if extra_lamports_in_pool > 0 { transfer( &mut context.banks_client, &context.payer, &context.last_blockhash, &accounts.stake_account, - extra_lamports_in_destination, + extra_lamports_in_pool, ) .await; } @@ -125,10 +127,10 @@ async fn success( // pool retains minstake assert_eq!(pool_stake_after, other_user_deposits + minimum_pool_balance); - // pool lamports otherwise unchanged. unexpected transfers affect nothing + // pool lamports otherwise unchanged assert_eq!( pool_lamports_after, - pool_lamports_before - expected_deposit + extra_lamports_in_destination + pool_lamports_before - expected_deposit + extra_lamports_in_pool, ); // alice has no tokens @@ -291,3 +293,209 @@ async fn fail_withdraw_to_onramp() { .unwrap_err(); check_error(e, SinglePoolError::InvalidPoolStakeAccountUsage); } + +#[test_matrix( + [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge] +)] +#[tokio::test] +async fn success_withdraw_from_inactive(stake_version: StakeProgramVersion) { + // this test would fail on bpf stake v1-4 because of a bug in Split + // when this assert fails, it means v5 is stable. delete this entire block + if stake_version == StakeProgramVersion::Stable { + assert!(stake_version + .basename() + .unwrap() + .starts_with("solana_stake_program-v4")); + + return; + } + + let Some(program_test) = program_test(stake_version) else { + return; + }; + let mut context = program_test.start_with_context().await; + + let accounts = SinglePoolAccounts::default(); + accounts + .initialize_for_withdraw(&mut context, TEST_STAKE_AMOUNT, None, true) + .await; + + force_deactivate_stake_account(&mut context, &accounts.stake_account).await; + + let (_, pool_stake_before, _) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + + // this proves we arent using delegation for math + assert_eq!( + pool_stake_before.unwrap().delegation.stake, + MANGLED_DELEGATION + ); + + let instructions = instruction::withdraw( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + get_token_balance(&mut context.banks_client, &accounts.alice_token).await, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let (_, _, alice_lamports_after) = + get_stake_account(&mut context.banks_client, &accounts.alice_stake.pubkey()).await; + assert_eq!( + alice_lamports_after, + TEST_STAKE_AMOUNT + get_stake_account_rent(&mut context.banks_client).await + ); +} + +#[test_matrix( + [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge] +)] +#[tokio::test] +async fn fail_disallowed_withdraw(stake_version: StakeProgramVersion) { + let Some(program_test) = program_test(stake_version) else { + return; + }; + let mut context = program_test.start_with_context().await; + + let accounts = SinglePoolAccounts::default(); + accounts + .initialize_for_withdraw(&mut context, TEST_STAKE_AMOUNT, None, true) + .await; + + let minimum_delegation = get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + // actually 0 withdraw fails + let instructions = instruction::withdraw( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + 0, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::WithdrawalTooSmall); + + // sub-minimum delegation withdraw fails + let instructions = instruction::withdraw( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + minimum_delegation - 1, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::WithdrawalTooSmall); + + // pump NEV higher. token is worth more but mostly backed by liquid sol + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.stake_account, + TEST_STAKE_AMOUNT * 10, + ) + .await; + + // withdrawal that cannot be delivered as stake fails + let instructions = instruction::withdraw( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + TEST_STAKE_AMOUNT, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::WithdrawalViolatesPoolRequirements); + + // slash the pool percentage that one token represents + let mut mint_account = get_account(&mut context.banks_client, &accounts.mint).await; + let mut mint_data = Mint::unpack_from_slice(&mint_account.data).unwrap(); + mint_data.supply *= 100; + Mint::pack(mint_data, &mut mint_account.data).unwrap(); + context.set_account(&accounts.mint, &mint_account.into()); + + // the minimum withdrawal from a non-inactive pool can only round to 0 if a pool has >1 billion sol + force_deactivate_stake_account(&mut context, &accounts.stake_account).await; + + // withdrawal that rounds to 0 fails + let instructions = instruction::withdraw( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + 1, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::WithdrawalTooSmall); +}