From 5bcc433b11d443eae0205aeb49d56ab51b02ce0f Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:00:50 -0700 Subject: [PATCH 1/3] program: require onramp to exist for deposit and withdraw --- program/src/instruction.rs | 6 +++--- program/src/processor.rs | 36 +++++++++++++++++++++++++++--------- program/tests/withdraw.rs | 2 +- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/program/src/instruction.rs b/program/src/instruction.rs index 2f276d23..c8177cf2 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -27,7 +27,7 @@ pub enum SinglePoolInstruction { /// Initialize the mint and main stake account for a new single-validator /// stake pool. The pool stake account must contain the rent-exempt /// minimum plus the minimum balance of 1 sol. No tokens will be minted; - /// to deposit more, use `Deposit` after `InitializeStake`. + /// to deposit more, use `Deposit` after `InitializeStake` and `InitializePoolOnRamp`. /// /// 0. `[]` Validator vote account /// 1. `[w]` Pool account @@ -119,7 +119,7 @@ pub enum SinglePoolInstruction { /// Create token metadata for the stake-pool token in the metaplex-token /// program. Step three of the permissionless three-stage initialization /// flow. - /// Note this instruction is not necessary for the pool to operate, to + /// Note this instruction is NOT necessary for the pool to operate, to /// ensure we cannot be broken by upstream. /// /// 0. `[]` Pool account @@ -156,7 +156,7 @@ pub enum SinglePoolInstruction { /// /// New pools created with `initialize()` will include this instruction /// automatically. Existing pools must use `InitializePoolOnRamp` to upgrade to - /// the latest version. + /// the latest version. Note the on-ramp IS necessary for the pool to operate. /// /// 0. `[]` Pool account /// 1. `[w]` Pool on-ramp account diff --git a/program/src/processor.rs b/program/src/processor.rs index d9897c84..1ddff69f 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -50,15 +50,20 @@ fn pool_net_asset_value( 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 + // NAV is all lamports in both accounts less rent + + let main_stake_value = pool_stake_info + .lamports() + .saturating_sub(pool_rent_exempt_reserve); + + let onramp_value = pool_onramp_info .lamports() - .saturating_add(pool_onramp_info.lamports()) - .saturating_sub(pool_rent_exempt_reserve) - .saturating_sub(onramp_rent_exempt_reserve) + .saturating_sub(onramp_rent_exempt_reserve); + + main_stake_value.saturating_add(onramp_value) } -/// Calculate pool tokens to mint, given outstanding token supply, pool NEV, and deposit amount +/// Calculate pool tokens to mint, given outstanding token supply, pool NAV, and deposit amount fn calculate_deposit_amount( pre_token_supply: u64, pre_pool_nev: u64, @@ -76,7 +81,7 @@ fn calculate_deposit_amount( } } -/// Calculate pool value to return, given outstanding token supply, pool NEV, and tokens to redeem +/// Calculate pool value to return, given outstanding token supply, pool NAV, and tokens to redeem fn calculate_withdraw_amount( pre_token_supply: u64, pre_pool_nev: u64, @@ -1048,6 +1053,12 @@ impl Processor { unreachable!(); }; + // onramp must exist + match deserialize_stake(pool_onramp_info) { + Ok(StakeStateV2::Initialized(_)) | Ok(StakeStateV2::Stake(_, _, _)) => (), + _ => return Err(SinglePoolError::OnRampDoesntExist.into()), + }; + // 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); @@ -1209,7 +1220,7 @@ impl Processor { // 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, + // however, because we calculate NAV 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)?; @@ -1235,7 +1246,14 @@ impl Processor { } }; - // withdraw amount is determined off pool NEV just like deposit amount + // onramp must exist. this does not create an edge case where withdrawals may be blocked, + // because we also require the onramp to exist for deposits + match deserialize_stake(pool_onramp_info) { + Ok(StakeStateV2::Initialized(_)) | Ok(StakeStateV2::Stake(_, _, _)) => (), + _ => return Err(SinglePoolError::OnRampDoesntExist.into()), + }; + + // withdraw amount is determined off pool NAV just like deposit amount let stake_to_withdraw = calculate_withdraw_amount(token_supply, pre_total_nev, token_amount) .ok_or(SinglePoolError::UnexpectedMathError)?; diff --git a/program/tests/withdraw.rs b/program/tests/withdraw.rs index 3c2c9c68..64e7c769 100644 --- a/program/tests/withdraw.rs +++ b/program/tests/withdraw.rs @@ -431,7 +431,7 @@ async fn fail_disallowed_withdraw(stake_version: StakeProgramVersion) { .unwrap_err(); check_error(e, SinglePoolError::WithdrawalTooSmall); - // pump NEV higher. token is worth more but mostly backed by liquid sol + // pump NAV higher. token is worth more but mostly backed by liquid sol transfer( &mut context.banks_client, &context.payer, From 1539438928dea7d64f9d6b8efae7ff27a28ed020 Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:20:47 -0700 Subject: [PATCH 2/3] jesus christ dude --- program/src/processor.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index 1ddff69f..d2afe530 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -66,16 +66,16 @@ fn pool_net_asset_value( /// Calculate pool tokens to mint, given outstanding token supply, pool NAV, and deposit amount fn calculate_deposit_amount( pre_token_supply: u64, - pre_pool_nev: u64, + pre_pool_nav: u64, user_deposit_amount: u64, ) -> Option { - if pre_pool_nev == 0 || pre_token_supply == 0 { + if pre_pool_nav == 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)?, + .checked_div(pre_pool_nav as u128)?, ) .ok() } @@ -84,10 +84,10 @@ fn calculate_deposit_amount( /// Calculate pool value to return, given outstanding token supply, pool NAV, and tokens to redeem fn calculate_withdraw_amount( pre_token_supply: u64, - pre_pool_nev: u64, + pre_pool_nav: u64, user_tokens_to_burn: u64, ) -> Option { - let numerator = (user_tokens_to_burn as u128).checked_mul(pre_pool_nev as u128)?; + let numerator = (user_tokens_to_burn as u128).checked_mul(pre_pool_nav as u128)?; let denominator = pre_token_supply as u128; if numerator < denominator || denominator == 0 { Some(0) @@ -1060,7 +1060,7 @@ impl Processor { }; // 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_total_nav = 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) { @@ -1125,7 +1125,7 @@ impl Processor { // deposit amount is determined off stake added because we return excess lamports let new_pool_tokens = - calculate_deposit_amount(token_supply, pre_total_nev, new_stake_added) + calculate_deposit_amount(token_supply, pre_total_nav, new_stake_added) .ok_or(SinglePoolError::UnexpectedMathError)?; if new_pool_tokens == 0 { @@ -1216,7 +1216,7 @@ impl Processor { 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); + let pre_total_nav = 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. @@ -1255,7 +1255,7 @@ impl Processor { // withdraw amount is determined off pool NAV just like deposit amount let stake_to_withdraw = - calculate_withdraw_amount(token_supply, pre_total_nev, token_amount) + calculate_withdraw_amount(token_supply, pre_total_nav, token_amount) .ok_or(SinglePoolError::UnexpectedMathError)?; // self-explanatory. we catch 0 deposit above so we only hit this if we rounded to 0 From 86af9518301619182092326a67a3735ec2d72543 Mon Sep 17 00:00:00 2001 From: hana <81144685+2501babe@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:22:20 -0700 Subject: [PATCH 3/3] move this lower --- program/src/processor.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/program/src/processor.rs b/program/src/processor.rs index d2afe530..f41fe49f 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -1215,9 +1215,6 @@ impl Processor { 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_nav = 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 NAV from all lamports in both pool accounts, @@ -1253,6 +1250,9 @@ impl Processor { _ => return Err(SinglePoolError::OnRampDoesntExist.into()), }; + // tokens for withdraw are determined off the total stakeable value of both pool-owned accounts + let pre_total_nav = pool_net_asset_value(pool_stake_info, pool_onramp_info, rent); + // withdraw amount is determined off pool NAV just like deposit amount let stake_to_withdraw = calculate_withdraw_amount(token_supply, pre_total_nav, token_amount)