From e1b5eb6545c0b792f66a7305ee0f32e55536033e Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:51:01 -0700 Subject: [PATCH 1/9] program: move to lamport-based accounting --- program/src/processor.rs | 170 ++++++++++++++++++++++++-------------- program/tests/deposit.rs | 15 +++- program/tests/withdraw.rs | 12 +-- 3 files changed, 129 insertions(+), 68 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index cf6bf02a..81d6e9b4 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -40,38 +40,54 @@ 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_sub(pool_rent_exempt_reserve) + .saturating_add(pool_onramp_info.lamports()) + .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) - } else { + if pre_pool_nev > 0 && pre_token_supply > 0 { 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() + } else { + Some(user_deposit_amount) } } -/// Calculate pool stake to return, given outstanding token supply, pool active -/// stake, and tokens to redeem +/// Calculate pool stake 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) - } else { + if numerator > denominator && denominator > 0 { u64::try_from(numerator.checked_div(denominator)?).ok() + } else { + 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,62 @@ 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 { + 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 + if stake_to_withdraw == 0 { + return Err(SinglePoolError::WithdrawalTooSmall.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()); } - // 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() { + // 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 + if stake_to_withdraw > withdrawable_value { + return Err(SinglePoolError::WithdrawalTooLarge.into()); + } + + // this is theoretically impossible but we guard because it would put the pool in an unrecoverable state + if stake_to_withdraw == pool_stake_info.lamports() { return Err(SinglePoolError::WithdrawalTooLarge.into()); } @@ -1221,7 +1274,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 +1288,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 +1593,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..1918d6c8 100644 --- a/program/tests/deposit.rs +++ b/program/tests/deposit.rs @@ -26,7 +26,7 @@ use spl_single_pool::find_default_deposit_account_address; #[test_matrix( [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], [false, true], - [0, 100_000], + [0, 1], [0, 100_000], [false, true] )] @@ -164,6 +164,17 @@ async fn success( alice_stake_before_deposit + rent_exempt_reserve + alice_extra_lamports }; + // excess lamports are counted in NEV, so expect fewer tokens + let expected_tokens = if pool_extra_lamports == 0 { + expected_deposit + } else if prior_deposit { + expected_deposit - 1 + } else if !activate { + expected_deposit - 11 + } else { + expected_deposit - 10 + }; + // deposit stake account is closed assert!(context .banks_client @@ -191,7 +202,7 @@ async fn success( // alice got tokens. no rewards have been paid so tokens correspond to stake 1:1 assert_eq!( get_token_balance(&mut context.banks_client, &accounts.alice_token).await, - expected_deposit, + expected_tokens, ); } diff --git a/program/tests/withdraw.rs b/program/tests/withdraw.rs index 9435b814..6fb7df7b 100644 --- a/program/tests/withdraw.rs +++ b/program/tests/withdraw.rs @@ -13,14 +13,14 @@ use { #[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 +52,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 +125,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 From 6043b2e8ca722e02e330664cb84ccb8a12ce1cb3 Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:46:05 -0700 Subject: [PATCH 2/9] add test --- program/src/processor.rs | 2 +- program/tests/deposit.rs | 72 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index 81d6e9b4..1d4b1667 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -53,8 +53,8 @@ fn pool_net_asset_value( // NEV is all lamports in both accounts less rent pool_stake_info .lamports() - .saturating_sub(pool_rent_exempt_reserve) .saturating_add(pool_onramp_info.lamports()) + .saturating_sub(pool_rent_exempt_reserve) .saturating_sub(onramp_rent_exempt_reserve) } diff --git a/program/tests/deposit.rs b/program/tests/deposit.rs index 1918d6c8..d4b0fce0 100644 --- a/program/tests/deposit.rs +++ b/program/tests/deposit.rs @@ -731,3 +731,75 @@ async fn all_activation_states( check_error(e, SinglePoolError::WrongStakeState); } } + +#[test_matrix( + [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], + [false, true] +)] +#[tokio::test] +async fn onramp_value_accounted(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 expected_additional_tokens = if activate { + advance_epoch(&mut context).await; + 0 + } else { + let rent = context.banks_client.get_rent().await.unwrap(); + rent.minimum_balance(StakeStateV2::size_of()) / 2 + }; + + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.onramp_account, + stake_amount, + ) + .await; + + // stake_amount == minimum_pool_balance + // thus pool starts with SA and onramp has been given SA so a SA deposit yields SA/2. + // since there are SA notional tokens backed by 2SA stake. + // (plus half rent if pool is activating) + + 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(); + + assert_eq!( + get_token_balance(&mut context.banks_client, &accounts.alice_token).await, + stake_amount / 2 + expected_additional_tokens + ); +} From a9d292969649e1c8c8dc032f8820787a89140369 Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:48:11 -0700 Subject: [PATCH 3/9] stop being clever --- program/src/processor.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index 1d4b1667..f72912f6 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -64,19 +64,19 @@ fn calculate_deposit_amount( pre_pool_nev: u64, user_deposit_amount: u64, ) -> Option { - if pre_pool_nev > 0 && pre_token_supply > 0 { + if pre_pool_nev == 0 || pre_token_supply == 0 { + Some(user_deposit_amount) + } else { u64::try_from( (user_deposit_amount as u128) .checked_mul(pre_token_supply as u128)? .checked_div(pre_pool_nev as u128)?, ) .ok() - } else { - Some(user_deposit_amount) } } -/// Calculate pool stake to return, given outstanding token supply, pool NEV, 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_nev: u64, @@ -84,10 +84,10 @@ fn calculate_withdraw_amount( ) -> Option { 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 { - u64::try_from(numerator.checked_div(denominator)?).ok() - } else { + if numerator < denominator || denominator == 0 { Some(0) + } else { + u64::try_from(numerator.checked_div(denominator)?).ok() } } From 2ddd51a00016b44fe7fb7b30c6e63a807f7842ce Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:52:27 -0700 Subject: [PATCH 4/9] test extra lamports separately --- program/tests/deposit.rs | 89 ++++++++++++++++++++++-------------- program/tests/helpers/mod.rs | 18 ++++++++ program/tests/replenish.rs | 19 -------- 3 files changed, 73 insertions(+), 53 deletions(-) diff --git a/program/tests/deposit.rs b/program/tests/deposit.rs index d4b0fce0..c295172a 100644 --- a/program/tests/deposit.rs +++ b/program/tests/deposit.rs @@ -26,7 +26,6 @@ use spl_single_pool::find_default_deposit_account_address; #[test_matrix( [StakeProgramVersion::Stable, StakeProgramVersion::Beta, StakeProgramVersion::Edge], [false, true], - [0, 1], [0, 100_000], [false, true] )] @@ -34,7 +33,6 @@ use spl_single_pool::find_default_deposit_account_address; 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( @@ -164,17 +151,6 @@ async fn success( alice_stake_before_deposit + rent_exempt_reserve + alice_extra_lamports }; - // excess lamports are counted in NEV, so expect fewer tokens - let expected_tokens = if pool_extra_lamports == 0 { - expected_deposit - } else if prior_deposit { - expected_deposit - 1 - } else if !activate { - expected_deposit - 11 - } else { - expected_deposit - 10 - }; - // deposit stake account is closed assert!(context .banks_client @@ -186,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 @@ -202,7 +178,7 @@ async fn success( // alice got tokens. no rewards have been paid so tokens correspond to stake 1:1 assert_eq!( get_token_balance(&mut context.banks_client, &accounts.alice_token).await, - expected_tokens, + expected_deposit, ); } @@ -737,7 +713,7 @@ async fn all_activation_states( [false, true] )] #[tokio::test] -async fn onramp_value_accounted(stake_version: StakeProgramVersion, activate: bool) { +async fn success_additional_value(stake_version: StakeProgramVersion, activate: bool) { let Some(program_test) = program_test(stake_version) else { return; }; @@ -755,14 +731,42 @@ async fn onramp_value_accounted(stake_version: StakeProgramVersion, activate: bo .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 { - let rent = context.banks_client.get_rent().await.unwrap(); - rent.minimum_balance(StakeStateV2::size_of()) / 2 + 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, @@ -773,9 +777,9 @@ async fn onramp_value_accounted(stake_version: StakeProgramVersion, activate: bo .await; // stake_amount == minimum_pool_balance - // thus pool starts with SA and onramp has been given SA so a SA deposit yields SA/2. - // since there are SA notional tokens backed by 2SA stake. - // (plus half rent if pool is activating) + // 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(), @@ -798,8 +802,25 @@ async fn onramp_value_accounted(stake_version: StakeProgramVersion, activate: bo .await .unwrap(); + // got the tokens we expect assert_eq!( get_token_balance(&mut context.banks_client, &accounts.alice_token).await, - stake_amount / 2 + expected_additional_tokens + 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/replenish.rs b/program/tests/replenish.rs index d8fed02e..c6a58052 100644 --- a/program/tests/replenish.rs +++ b/program/tests/replenish.rs @@ -7,7 +7,6 @@ use { solana_account::AccountSharedData, solana_clock::Clock, solana_program_test::*, - solana_pubkey::Pubkey, solana_signer::Signer, solana_stake_interface::{ instruction as stake_instruction, @@ -20,24 +19,6 @@ use { 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], From 439cf91120b231f2fa220cdf4f0a02bbf69669fe Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 10 Apr 2026 03:45:42 -0700 Subject: [PATCH 5/9] shuffle withdraw errors --- program/src/error.rs | 19 ++++++++++++++----- program/src/processor.rs | 23 +++++++++++++++++------ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/program/src/error.rs b/program/src/error.rs index 980dc00b..ebc04caa 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 1sol. + #[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 f72912f6..87943ebe 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1198,6 +1198,10 @@ impl Processor { return Err(SinglePoolError::InvalidPoolStakeAccountUsage.into()); } + if token_amount == 0 { + return Err(SinglePoolError::WithdrawalTooSmall.into()); + } + let minimum_delegation = stake::tools::get_minimum_delegation()?; // tokens for withdraw are determined off the total stakeable value of both pool-owned accounts @@ -1241,22 +1245,29 @@ impl Processor { 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 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()); } - // this is theoretically impossible but we guard because it would put the pool in an unrecoverable state - if stake_to_withdraw == pool_stake_info.lamports() { - return Err(SinglePoolError::WithdrawalTooLarge.into()); - } - // burn user tokens corresponding to the amount of stake they wish to withdraw Self::token_burn( pool_info.key, From ad02f8a86e625c9348f06f73574c1a8fc80344c0 Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 10 Apr 2026 04:00:54 -0700 Subject: [PATCH 6/9] aaaAAAAAgit add src/error.rs! --- program/src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/program/src/error.rs b/program/src/error.rs index ebc04caa..7555ea09 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -107,7 +107,7 @@ pub enum SinglePoolError { #[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 1sol. + /// This can only occur if the Stake Program minimum delegation increases above 1 sol. #[error("WithdrawalViolatesPoolRequirements")] WithdrawalViolatesPoolRequirements, } From f9b3fa753fcad70752995a866b3d731f07272f41 Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 10 Apr 2026 04:30:52 -0700 Subject: [PATCH 7/9] test inactive --- program/tests/helpers/stake.rs | 34 ++++++++++++++++- program/tests/replenish.rs | 37 ++----------------- program/tests/withdraw.rs | 67 ++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 35 deletions(-) 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 c6a58052..8652f43b 100644 --- a/program/tests/replenish.rs +++ b/program/tests/replenish.rs @@ -4,15 +4,11 @@ mod helpers; use { helpers::*, - solana_account::AccountSharedData, solana_clock::Clock, solana_program_test::*, 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}, @@ -58,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 6fb7df7b..5244a085 100644 --- a/program/tests/withdraw.rs +++ b/program/tests/withdraw.rs @@ -291,3 +291,70 @@ 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 + ); +} From f21349681636f9d54b33babcc7a368791d7bac2e Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 10 Apr 2026 05:34:48 -0700 Subject: [PATCH 8/9] withdraw tests --- program/src/processor.rs | 2 +- program/tests/withdraw.rs | 141 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index 87943ebe..18e46e11 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1240,7 +1240,7 @@ impl Processor { calculate_withdraw_amount(token_supply, pre_total_nev, token_amount) .ok_or(SinglePoolError::UnexpectedMathError)?; - // self-explanatory + // 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()); } diff --git a/program/tests/withdraw.rs b/program/tests/withdraw.rs index 5244a085..a19a8e73 100644 --- a/program/tests/withdraw.rs +++ b/program/tests/withdraw.rs @@ -3,10 +3,12 @@ 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}, }; @@ -358,3 +360,142 @@ async fn success_withdraw_from_inactive(stake_version: StakeProgramVersion) { 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-inactve 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); +} From 450127ed5bf3b1964dc4f905b879f7f21c2b6830 Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:45:59 -0700 Subject: [PATCH 9/9] typo --- program/tests/withdraw.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/program/tests/withdraw.rs b/program/tests/withdraw.rs index a19a8e73..3c2c9c68 100644 --- a/program/tests/withdraw.rs +++ b/program/tests/withdraw.rs @@ -472,7 +472,7 @@ async fn fail_disallowed_withdraw(stake_version: StakeProgramVersion) { Mint::pack(mint_data, &mut mint_account.data).unwrap(); context.set_account(&accounts.mint, &mint_account.into()); - // the minimum withdrawal from a non-inactve pool can only round to 0 if a pool has >1 billion sol + // 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