fix(pumpswap): validate virtual reserve quotes

This commit is contained in:
0xfnzero
2026-07-17 02:15:20 +08:00
parent dd41dd4f87
commit fb1ff176d0
13 changed files with 914 additions and 111 deletions
+46 -10
View File
@@ -54,7 +54,9 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
let base_mint = protocol_params.base_mint;
let quote_mint = protocol_params.quote_mint;
let pool_base_token_reserves = protocol_params.pool_base_token_reserves;
let pool_quote_token_reserves = protocol_params.effective_quote_reserves()?;
let pool_quote_token_reserves = protocol_params.pool_quote_token_reserves;
let virtual_quote_reserves = protocol_params.virtual_quote_reserves;
protocol_params.effective_quote_reserves()?;
let params_coin_creator_vault_ata = protocol_params.coin_creator_vault_ata;
let params_coin_creator_vault_authority = protocol_params.coin_creator_vault_authority;
let create_input_ata = params.create_input_mint_ata;
@@ -97,9 +99,10 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
params.slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE),
pool_base_token_reserves,
pool_quote_token_reserves,
virtual_quote_reserves,
&fee_basis_points,
)
.unwrap();
.map_err(anyhow::Error::msg)?;
// base_amount_out, max_quote_amount_in
(result.base, result.max_quote)
} else {
@@ -108,9 +111,10 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
params.slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE),
pool_base_token_reserves,
pool_quote_token_reserves,
virtual_quote_reserves,
&fee_basis_points,
)
.unwrap();
.map_err(anyhow::Error::msg)?;
// min_quote_amount_out, base_amount_in
(result.min_quote, params.input_amount.unwrap_or(0))
};
@@ -283,7 +287,9 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
let base_mint = protocol_params.base_mint;
let quote_mint = protocol_params.quote_mint;
let pool_base_token_reserves = protocol_params.pool_base_token_reserves;
let pool_quote_token_reserves = protocol_params.effective_quote_reserves()?;
let pool_quote_token_reserves = protocol_params.pool_quote_token_reserves;
let virtual_quote_reserves = protocol_params.virtual_quote_reserves;
protocol_params.effective_quote_reserves()?;
let pool_base_token_account = protocol_params.pool_base_token_account;
let pool_quote_token_account = protocol_params.pool_quote_token_account;
let params_coin_creator_vault_ata = protocol_params.coin_creator_vault_ata;
@@ -305,8 +311,8 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
return Err(anyhow!("Pool must contain WSOL or USDC"));
}
if params.input_amount.is_none() {
return Err(anyhow!("Token amount is not set"));
if params.input_amount.unwrap_or_default() == 0 {
return Err(anyhow!("Token amount must be greater than zero"));
}
// ========================================
@@ -320,6 +326,9 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
let fee_basis_points = protocol_params.fee_basis_points;
let (token_amount, sol_amount) = if let Some(output_amount) = params.fixed_output_amount {
if quote_is_wsol_or_usdc && output_amount > pool_quote_token_reserves {
return Err(anyhow!("Minimum quote output exceeds the real quote-vault balance"));
}
(params.input_amount.unwrap(), output_amount)
} else if quote_is_wsol_or_usdc {
let result = sell_base_input_internal_with_fees(
@@ -327,9 +336,10 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
params.slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE),
pool_base_token_reserves,
pool_quote_token_reserves,
virtual_quote_reserves,
&fee_basis_points,
)
.unwrap();
.map_err(anyhow::Error::msg)?;
// base_amount_in, min_quote_amount_out
(params.input_amount.unwrap(), result.min_quote)
} else {
@@ -338,9 +348,10 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
params.slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE),
pool_base_token_reserves,
pool_quote_token_reserves,
virtual_quote_reserves,
&fee_basis_points,
)
.unwrap();
.map_err(anyhow::Error::msg)?;
// max_quote_amount_in, base_amount_out
(result.max_quote, result.base)
};
@@ -617,6 +628,29 @@ mod tests {
assert_eq!(u64::from_le_bytes(ix.data[16..24].try_into().unwrap()), 42);
}
#[tokio::test]
async fn pumpswap_sell_fixed_output_rejects_real_vault_overflow() {
let mut params = swap_params(TradeType::Sell, Some(42));
let DexParamEnum::PumpSwap(protocol_params) = &mut params.protocol_params else {
unreachable!();
};
protocol_params.pool_quote_token_reserves = 41;
let error = PumpSwapInstructionBuilder.build_sell_instructions(&params).await.unwrap_err();
assert_eq!(error.to_string(), "Minimum quote output exceeds the real quote-vault balance");
}
#[tokio::test]
async fn pumpswap_sell_rejects_zero_input() {
let mut params = swap_params(TradeType::Sell, None);
params.input_amount = Some(0);
let error = PumpSwapInstructionBuilder.build_sell_instructions(&params).await.unwrap_err();
assert_eq!(error.to_string(), "Token amount must be greater than zero");
}
#[tokio::test]
async fn pumpswap_usdc_buy_create_input_builds_usdc_ata() {
let mut params = swap_params(TradeType::Buy, Some(42));
@@ -670,7 +704,8 @@ mod tests {
1_000_000,
100,
1_000_000_000,
2_500_000_000,
2_000_000_000,
500_000_000,
&crate::instruction::utils::pumpswap::PumpSwapFeeBasisPoints::new(20, 5, 0),
)
.unwrap();
@@ -693,7 +728,8 @@ mod tests {
100_000,
100,
1_000_000_000,
2_500_000_000,
2_000_000_000,
500_000_000,
&crate::instruction::utils::pumpswap::PumpSwapFeeBasisPoints::new(20, 5, 0),
)
.unwrap();
+15 -4
View File
@@ -740,10 +740,11 @@ pub async fn fetch_pool(
Ok(pool)
}
/// Known allocated Pool account sizes. The July 2026 layout carrying
/// `virtual_quote_reserves` is allocated to 300 bytes on-chain.
/// Known allocated Pool account sizes. Current accounts may be serialized to
/// exactly 261 bytes or retain a larger historical allocation.
const POOL_DATA_LEN_LEGACY: u64 = 8 + 244;
const POOL_DATA_LEN_CURRENT: u64 = 300;
const POOL_DATA_LEN_CURRENT: u64 = 8 + 253;
const POOL_DATA_LEN_PADDED: u64 = 300;
const POOL_DATA_LEN_EXTENDED: u64 = 643;
/// Run getProgramAccounts with a Memcmp filter, querying known Pool sizes in parallel.
@@ -770,13 +771,15 @@ async fn get_program_accounts_known_sizes(
};
let program_id = accounts::AMM_PROGRAM;
#[allow(deprecated)]
let (legacy_result, current_result, extended_result) = tokio::join!(
let (legacy_result, current_result, padded_result, extended_result) = tokio::join!(
rpc.get_program_accounts_with_config(&program_id, make_config(POOL_DATA_LEN_LEGACY)),
rpc.get_program_accounts_with_config(&program_id, make_config(POOL_DATA_LEN_CURRENT)),
rpc.get_program_accounts_with_config(&program_id, make_config(POOL_DATA_LEN_PADDED)),
rpc.get_program_accounts_with_config(&program_id, make_config(POOL_DATA_LEN_EXTENDED)),
);
let mut all = legacy_result.unwrap_or_default();
all.extend(current_result.unwrap_or_default());
all.extend(padded_result.unwrap_or_default());
all.extend(extended_result.unwrap_or_default());
Ok(all)
}
@@ -990,4 +993,12 @@ mod tests {
);
assert_eq!(fees, PumpSwapFeeBasisPoints::new(20, 5, 75));
}
#[test]
fn pumpswap_pool_queries_cover_current_serialized_and_padded_sizes() {
assert_eq!(POOL_DATA_LEN_LEGACY, 252);
assert_eq!(POOL_DATA_LEN_CURRENT, 261);
assert_eq!(POOL_DATA_LEN_PADDED, 300);
assert_eq!(POOL_DATA_LEN_EXTENDED, 643);
}
}
+3 -1
View File
@@ -78,7 +78,7 @@ pub fn pool_decode(data: &[u8]) -> Option<Pool> {
/// Compute the quote reserves used by PumpSwap pricing.
///
/// Returns `None` when the signed sum is negative or cannot fit in a `u64`.
/// Returns `None` when the signed sum is non-positive or cannot fit in a `u64`.
#[inline]
pub fn effective_quote_reserves(
quote_vault_balance: u64,
@@ -87,6 +87,7 @@ pub fn effective_quote_reserves(
i128::from(quote_vault_balance)
.checked_add(virtual_quote_reserves)
.and_then(|reserves| u64::try_from(reserves).ok())
.filter(|reserves| *reserves != 0)
}
#[cfg(test)]
@@ -131,6 +132,7 @@ mod tests {
fn effective_reserves_support_signed_virtual_amounts_and_reject_invalid_sums() {
assert_eq!(effective_quote_reserves(1_000, 250), Some(1_250));
assert_eq!(effective_quote_reserves(1_000, -250), Some(750));
assert_eq!(effective_quote_reserves(1_000, -1_000), None);
assert_eq!(effective_quote_reserves(100, -101), None);
assert_eq!(effective_quote_reserves(u64::MAX, 1), None);
}
+163 -18
View File
@@ -7,6 +7,23 @@ use crate::instruction::utils::pumpswap::accounts::{
use crate::instruction::utils::pumpswap::PumpSwapFeeBasisPoints;
use solana_sdk::pubkey::Pubkey;
#[inline]
fn effective_quote_reserve(
quote_reserve: u64,
virtual_quote_reserves: i128,
) -> Result<u64, String> {
crate::instruction::utils::pumpswap_types::effective_quote_reserves(
quote_reserve,
virtual_quote_reserves,
)
.filter(|reserve| *reserve != 0)
.ok_or_else(|| {
format!(
"Invalid effective quote reserves: raw={quote_reserve}, virtual={virtual_quote_reserves}."
)
})
}
/// Creator-side fee bps: fixed coin-creator fee when a creator vault applies, plus optional
/// cashback fee bps for cashback-enabled coins (see Pump AMM / parser event field).
#[inline]
@@ -69,7 +86,8 @@ pub struct SellQuoteInputResult {
/// * `base` - Amount of base tokens to buy
/// * `slippage_basis_points` - Slippage tolerance in basis points (100 = 1%)
/// * `base_reserve` - Base token reserves in the pool
/// * `quote_reserve` - Quote token reserves in the pool
/// * `quote_reserve` - Raw quote-vault balance
/// * `virtual_quote_reserves` - Signed virtual quote reserves from the same pool snapshot
/// * `coin_creator` - Token creator address
/// * `cashback_fee_basis_points` - Extra fee bps for cashback coins (from on-chain / events); use `0` if unknown
///
@@ -80,6 +98,7 @@ pub fn buy_base_input_internal(
slippage_basis_points: u64,
base_reserve: u64,
quote_reserve: u64,
virtual_quote_reserves: i128,
coin_creator: &Pubkey,
cashback_fee_basis_points: u64,
) -> Result<BuyBaseInputResult, String> {
@@ -88,6 +107,7 @@ pub fn buy_base_input_internal(
slippage_basis_points,
base_reserve,
quote_reserve,
virtual_quote_reserves,
&PumpSwapFeeBasisPoints::new(
LP_FEE_BASIS_POINTS,
PROTOCOL_FEE_BASIS_POINTS,
@@ -101,17 +121,19 @@ pub fn buy_base_input_internal_with_fees(
slippage_basis_points: u64,
base_reserve: u64,
quote_reserve: u64,
virtual_quote_reserves: i128,
fee_basis_points: &PumpSwapFeeBasisPoints,
) -> Result<BuyBaseInputResult, String> {
if base_reserve == 0 || quote_reserve == 0 {
return Err("Invalid input: 'baseReserve' or 'quoteReserve' cannot be zero.".to_string());
}
let effective_quote_reserve = effective_quote_reserve(quote_reserve, virtual_quote_reserves)?;
if base > base_reserve {
return Err("Cannot buy more base tokens than the pool reserves.".to_string());
}
// Calculate required quote amount using constant product formula
let numerator = (quote_reserve as u128) * (base as u128);
let numerator = (effective_quote_reserve as u128) * (base as u128);
let denominator = base_reserve - base;
if denominator == 0 {
@@ -148,7 +170,8 @@ pub fn buy_base_input_internal_with_fees(
/// * `quote` - Amount of quote tokens to spend
/// * `slippage_basis_points` - Slippage tolerance in basis points (100 = 1%)
/// * `base_reserve` - Base token reserves in the pool
/// * `quote_reserve` - Quote token reserves in the pool
/// * `quote_reserve` - Raw quote-vault balance
/// * `virtual_quote_reserves` - Signed virtual quote reserves from the same pool snapshot
/// * `coin_creator` - Token creator address
/// * `cashback_fee_basis_points` - Extra fee bps for cashback coins; use `0` if unknown
///
@@ -159,6 +182,7 @@ pub fn buy_quote_input_internal(
slippage_basis_points: u64,
base_reserve: u64,
quote_reserve: u64,
virtual_quote_reserves: i128,
coin_creator: &Pubkey,
cashback_fee_basis_points: u64,
) -> Result<BuyQuoteInputResult, String> {
@@ -167,6 +191,7 @@ pub fn buy_quote_input_internal(
slippage_basis_points,
base_reserve,
quote_reserve,
virtual_quote_reserves,
&PumpSwapFeeBasisPoints::new(
LP_FEE_BASIS_POINTS,
PROTOCOL_FEE_BASIS_POINTS,
@@ -180,11 +205,13 @@ pub fn buy_quote_input_internal_with_fees(
slippage_basis_points: u64,
base_reserve: u64,
quote_reserve: u64,
virtual_quote_reserves: i128,
fee_basis_points: &PumpSwapFeeBasisPoints,
) -> Result<BuyQuoteInputResult, String> {
if base_reserve == 0 || quote_reserve == 0 {
return Err("Invalid input: 'baseReserve' or 'quoteReserve' cannot be zero.".to_string());
}
let effective_quote_reserve = effective_quote_reserve(quote_reserve, virtual_quote_reserves)?;
// Calculate total fee basis points
let total_fee_bps = fee_basis_points
@@ -208,7 +235,7 @@ pub fn buy_quote_input_internal_with_fees(
// Calculate base amount out using constant product formula
let numerator = (base_reserve as u128) * input_amount;
let denominator_effective = (quote_reserve as u128) + input_amount;
let denominator_effective = (effective_quote_reserve as u128) + input_amount;
if denominator_effective == 0 {
return Err("Pool would be depleted; denominator is zero.".to_string());
@@ -232,7 +259,8 @@ pub fn buy_quote_input_internal_with_fees(
/// * `base` - Amount of base tokens to sell
/// * `slippage_basis_points` - Slippage tolerance in basis points (100 = 1%)
/// * `base_reserve` - Base token reserves in the pool
/// * `quote_reserve` - Quote token reserves in the pool
/// * `quote_reserve` - Raw quote-vault balance
/// * `virtual_quote_reserves` - Signed virtual quote reserves from the same pool snapshot
/// * `coin_creator` - Token creator address
/// * `cashback_fee_basis_points` - Extra fee bps for cashback coins; use `0` if unknown
///
@@ -243,6 +271,7 @@ pub fn sell_base_input_internal(
slippage_basis_points: u64,
base_reserve: u64,
quote_reserve: u64,
virtual_quote_reserves: i128,
coin_creator: &Pubkey,
cashback_fee_basis_points: u64,
) -> Result<SellBaseInputResult, String> {
@@ -251,6 +280,7 @@ pub fn sell_base_input_internal(
slippage_basis_points,
base_reserve,
quote_reserve,
virtual_quote_reserves,
&PumpSwapFeeBasisPoints::new(
LP_FEE_BASIS_POINTS,
PROTOCOL_FEE_BASIS_POINTS,
@@ -264,14 +294,16 @@ pub fn sell_base_input_internal_with_fees(
slippage_basis_points: u64,
base_reserve: u64,
quote_reserve: u64,
virtual_quote_reserves: i128,
fee_basis_points: &PumpSwapFeeBasisPoints,
) -> Result<SellBaseInputResult, String> {
if base_reserve == 0 || quote_reserve == 0 {
return Err("Invalid input: 'baseReserve' or 'quoteReserve' cannot be zero.".to_string());
}
let effective_quote_reserve = effective_quote_reserve(quote_reserve, virtual_quote_reserves)?;
// Calculate quote amount out using constant product formula
let quote_amount_out = ((quote_reserve as u128) * (base as u128)
let quote_amount_out = ((effective_quote_reserve as u128) * (base as u128)
/ ((base_reserve as u128) + (base as u128))) as u64;
// Calculate fees
@@ -290,6 +322,10 @@ pub fn sell_base_input_internal_with_fees(
if total_fees > quote_amount_out {
return Err("Fees exceed total output; final quote is negative.".to_string());
}
let quote_vault_outflow = quote_amount_out - lp_fee;
if quote_vault_outflow > quote_reserve {
return Err("Insufficient real quote reserves to cover the sell output.".to_string());
}
let final_quote = quote_amount_out - total_fees;
// Calculate min quote with slippage
@@ -310,12 +346,22 @@ fn calculate_quote_amount_out(
lp_fee_basis_points: u64,
protocol_fee_basis_points: u64,
coin_creator_fee_basis_points: u64,
) -> u64 {
let total_fee_basis_points =
lp_fee_basis_points + protocol_fee_basis_points + coin_creator_fee_basis_points;
let denominator = MAX_FEE_BASIS_POINTS - total_fee_basis_points;
ceil_div((user_quote_amount_out as u128) * (MAX_FEE_BASIS_POINTS as u128), denominator as u128)
as u64
) -> Result<u64, String> {
let total_fee_basis_points = lp_fee_basis_points
.checked_add(protocol_fee_basis_points)
.and_then(|fees| fees.checked_add(coin_creator_fee_basis_points))
.ok_or_else(|| "Fee basis points overflow.".to_string())?;
let denominator = MAX_FEE_BASIS_POINTS
.checked_sub(total_fee_basis_points)
.ok_or_else(|| "Total fee basis points must be less than 10,000.".to_string())?;
if denominator == 0 {
return Err("Total fee basis points must be less than 10,000.".to_string());
}
let raw_quote = ceil_div(
(user_quote_amount_out as u128) * (MAX_FEE_BASIS_POINTS as u128),
denominator as u128,
);
u64::try_from(raw_quote).map_err(|_| "Calculated quote amount exceeds u64.".to_string())
}
/// Calculate base tokens needed to receive a specific amount of quote tokens
@@ -324,7 +370,8 @@ fn calculate_quote_amount_out(
/// * `quote` - Desired amount of quote tokens to receive
/// * `slippage_basis_points` - Slippage tolerance in basis points (100 = 1%)
/// * `base_reserve` - Base token reserves in the pool
/// * `quote_reserve` - Quote token reserves in the pool
/// * `quote_reserve` - Raw quote-vault balance
/// * `virtual_quote_reserves` - Signed virtual quote reserves from the same pool snapshot
/// * `coin_creator` - Token creator address
/// * `cashback_fee_basis_points` - Extra fee bps for cashback coins; use `0` if unknown
///
@@ -335,6 +382,7 @@ pub fn sell_quote_input_internal(
slippage_basis_points: u64,
base_reserve: u64,
quote_reserve: u64,
virtual_quote_reserves: i128,
coin_creator: &Pubkey,
cashback_fee_basis_points: u64,
) -> Result<SellQuoteInputResult, String> {
@@ -343,6 +391,7 @@ pub fn sell_quote_input_internal(
slippage_basis_points,
base_reserve,
quote_reserve,
virtual_quote_reserves,
&PumpSwapFeeBasisPoints::new(
LP_FEE_BASIS_POINTS,
PROTOCOL_FEE_BASIS_POINTS,
@@ -356,6 +405,7 @@ pub fn sell_quote_input_internal_with_fees(
slippage_basis_points: u64,
base_reserve: u64,
quote_reserve: u64,
virtual_quote_reserves: i128,
fee_basis_points: &PumpSwapFeeBasisPoints,
) -> Result<SellQuoteInputResult, String> {
if base_reserve == 0 || quote_reserve == 0 {
@@ -364,6 +414,7 @@ pub fn sell_quote_input_internal_with_fees(
if quote > quote_reserve {
return Err("Cannot receive more quote tokens than the pool quote reserves.".to_string());
}
let effective_quote_reserve = effective_quote_reserve(quote_reserve, virtual_quote_reserves)?;
// Calculate raw quote amount including fees
let raw_quote = calculate_quote_amount_out(
@@ -371,19 +422,113 @@ pub fn sell_quote_input_internal_with_fees(
fee_basis_points.lp_fee_basis_points,
fee_basis_points.protocol_fee_basis_points,
fee_basis_points.coin_creator_fee_basis_points,
);
)?;
let lp_fee =
compute_fee(raw_quote as u128, fee_basis_points.lp_fee_basis_points as u128) as u64;
if raw_quote.saturating_sub(lp_fee) > quote_reserve {
return Err("Insufficient real quote reserves to cover the sell output.".to_string());
}
// Calculate base amount needed using inverse constant product formula
if raw_quote >= quote_reserve {
if raw_quote >= effective_quote_reserve {
return Err("Invalid input: Desired quote amount exceeds available reserve.".to_string());
}
let base_amount_in =
ceil_div((base_reserve as u128) * (raw_quote as u128), (quote_reserve - raw_quote) as u128)
as u64;
let base_amount_in = ceil_div(
(base_reserve as u128) * (raw_quote as u128),
(effective_quote_reserve - raw_quote) as u128,
) as u64;
// Calculate min quote with slippage
let min_quote = calculate_with_slippage_sell(quote, slippage_basis_points);
Ok(SellQuoteInputResult { internal_raw_quote: raw_quote, base: base_amount_in, min_quote })
}
#[cfg(test)]
mod tests {
use super::*;
fn fees() -> PumpSwapFeeBasisPoints {
PumpSwapFeeBasisPoints::new(20, 5, 0)
}
#[test]
fn buy_uses_effective_quote_reserves() {
let result =
buy_quote_input_internal_with_fees(10_000, 100, 1_000_000, 1_000_000, 500_000, &fees())
.unwrap();
let without_virtual =
buy_quote_input_internal_with_fees(10_000, 100, 1_000_000, 1_000_000, 0, &fees())
.unwrap();
assert!(result.base < without_virtual.base);
}
#[test]
fn sell_rejects_output_not_covered_by_real_quote_vault() {
let error = sell_base_input_internal_with_fees(
1_000_000,
100,
1_000_000,
1_000,
1_000_000,
&fees(),
)
.unwrap_err();
assert_eq!(error, "Insufficient real quote reserves to cover the sell output.");
}
#[test]
fn exact_quote_sell_uses_effective_reserve_for_denominator() {
let result =
sell_quote_input_internal_with_fees(500, 100, 1_000_000, 1_000, 1_000_000, &fees())
.unwrap();
assert!(result.base < 1_000);
}
#[test]
fn exact_quote_sell_rejects_output_above_real_quote_vault() {
let error =
sell_quote_input_internal_with_fees(1_001, 100, 1_000_000, 1_000, 1_000_000, &fees())
.unwrap_err();
assert_eq!(error, "Cannot receive more quote tokens than the pool quote reserves.");
}
#[test]
fn negative_virtual_reserves_are_applied() {
let result = buy_quote_input_internal_with_fees(
10_000,
100,
1_000_000,
1_000_000,
-500_000,
&fees(),
)
.unwrap();
let without_virtual =
buy_quote_input_internal_with_fees(10_000, 100, 1_000_000, 1_000_000, 0, &fees())
.unwrap();
assert!(result.base > without_virtual.base);
}
#[test]
fn zero_effective_quote_reserves_are_rejected() {
let error = buy_quote_input_internal_with_fees(
10_000,
100,
1_000_000,
1_000_000,
-1_000_000,
&fees(),
)
.unwrap_err();
assert_eq!(error, "Invalid effective quote reserves: raw=1000000, virtual=-1000000.");
}
}