From fb1ff176d0c75e75db52bbe616323f2b3419a9c4 Mon Sep 17 00:00:00 2001 From: 0xfnzero <0xfnzero@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:15:20 +0800 Subject: [PATCH] fix(pumpswap): validate virtual reserve quotes --- Cargo.toml | 8 +- examples/pumpswap_trading/Cargo.toml | 2 +- examples/pumpswap_trading/README.md | 2 +- examples/pumpswap_trading/README_CN.md | 2 +- examples/pumpswap_trading/src/main.rs | 138 +++--- patches/solana-keypair/Cargo.toml | 97 ++++ patches/solana-keypair/src/lib.rs | 444 +++++++++++++++++++ patches/solana-keypair/src/seed_derivable.rs | 49 ++ patches/solana-keypair/src/signable.rs | 23 + src/instruction/pumpswap.rs | 56 ++- src/instruction/utils/pumpswap.rs | 19 +- src/instruction/utils/pumpswap_types.rs | 4 +- src/utils/calc/pumpswap.rs | 181 +++++++- 13 files changed, 914 insertions(+), 111 deletions(-) create mode 100644 patches/solana-keypair/Cargo.toml create mode 100644 patches/solana-keypair/src/lib.rs create mode 100644 patches/solana-keypair/src/seed_derivable.rs create mode 100644 patches/solana-keypair/src/signable.rs diff --git a/Cargo.toml b/Cargo.toml index 643980d..5afc455 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sol-trade-sdk" -version = "4.0.23" +version = "5.0.0" edition = "2021" authors = [ "William ", @@ -123,6 +123,12 @@ memmap2 = "0.9" num_cpus = "1.16" libc = "0.2" +# solana-keypair 3.1.2 assumes five8's DecodeError implements std::error::Error. +# Mixed Solana dependency graphs can resolve five8 1.0 against five8_core 0.1, +# so use the patched upstream source that wraps decode failures safely. +[patch.crates-io] +solana-keypair = { path = "patches/solana-keypair" } + # 🚀 编译器优化配置 - 平衡性能与编译速度 [profile.release] opt-level = 3 # 最高优化级别(不影响编译速度) diff --git a/examples/pumpswap_trading/Cargo.toml b/examples/pumpswap_trading/Cargo.toml index cc3f255..96591bb 100644 --- a/examples/pumpswap_trading/Cargo.toml +++ b/examples/pumpswap_trading/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] sol-trade-sdk = { path = "../.." } -solana-streamer-sdk = "0.5.0" +solana-streamer-sdk = { version = "2.0.0", git = "https://github.com/0xfnzero/solana-streamer", rev = "85c6cc901ad3f1bf8fe8010d79b92ecdd0be02b4" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/pumpswap_trading/README.md b/examples/pumpswap_trading/README.md index b68d5b0..d392bcf 100644 --- a/examples/pumpswap_trading/README.md +++ b/examples/pumpswap_trading/README.md @@ -21,7 +21,7 @@ cargo run --release --package pumpswap_trading - The buy uses `BuyAmount::WithMaxInput`, which applies slippage to maximum quote cost and is appropriate when fill priority matters. - Buy parameters use post-trade reserves and LP/protocol/creator fee bps from the event. -- `solana-streamer-sdk 0.5.0` predates the appended `virtual_quote_reserves` event field, so this compatibility example reads that Pool field once before quoting. When the parser exposes the field, pass it directly to `PumpSwapParams::from_trade_with_fee_basis_points` or keep it in a Pool account cache to remove this RPC from the hot path. +- The event's raw and virtual quote reserves come from the same transaction snapshot. The hot path does not fetch the Pool account, avoiding both added latency and mixed-slot quotes. - The example records the pre-buy balance and sells only the confirmed balance increase. It refreshes pool state and blockhash before selling. - Use `BuyAmount::ExactInput` when the quote spend must be exact. That mode protects minimum output and can fail more often in an active pool. diff --git a/examples/pumpswap_trading/README_CN.md b/examples/pumpswap_trading/README_CN.md index 1a879b6..4aca4e6 100644 --- a/examples/pumpswap_trading/README_CN.md +++ b/examples/pumpswap_trading/README_CN.md @@ -19,7 +19,7 @@ cargo run --release --package pumpswap_trading - 买入使用 `BuyAmount::WithMaxInput`,适合优先成交的跟单/狙击场景,滑点限制最大 quote 成本。 - 买入参数使用事件中的成交后储备和 LP/protocol/creator fee bps。 -- `solana-streamer-sdk 0.5.0` 尚未暴露追加的 `virtual_quote_reserves` 事件字段,因此该兼容示例会在报价前读取一次 Pool 字段。解析器暴露该字段后,应直接传给 `PumpSwapParams::from_trade_with_fee_basis_points`,或维护 Pool 账户缓存,以移除热路径中的这次 RPC。 +- 原始 quote 储备和虚拟 quote 储备均来自同一笔交易的事件快照;热路径不再查询 Pool 账户,避免额外延迟和跨 slot 混合报价。 - 示例记录买前余额,只卖出确认后的余额增量;卖出前重新获取池状态和 blockhash。 - 若业务必须精确花费 quote,应改用 `BuyAmount::ExactInput`。这会启用最小输出保护,在活跃池中更容易因状态变化而失败。 diff --git a/examples/pumpswap_trading/src/main.rs b/examples/pumpswap_trading/src/main.rs index 37c1759..28b2a06 100644 --- a/examples/pumpswap_trading/src/main.rs +++ b/examples/pumpswap_trading/src/main.rs @@ -1,5 +1,4 @@ use sol_trade_sdk::common::{clock::now_micros, SolanaRpcClient, TradeConfig}; -use sol_trade_sdk::instruction::utils::pumpswap::fetch_pool; use sol_trade_sdk::TradeTokenType; use sol_trade_sdk::{ common::AnyResult, @@ -12,18 +11,16 @@ use sol_trade_sdk::{ }; use solana_commitment_config::CommitmentConfig; use solana_sdk::{hash::Hash, pubkey::Pubkey}; +use solana_streamer_sdk::streaming::event_parser::protocols::pumpswap::parser::PUMPSWAP_PROGRAM_ID; use solana_streamer_sdk::streaming::event_parser::{ common::filter::EventTypeFilter, protocols::pumpswap::PumpSwapBuyEvent, }; use solana_streamer_sdk::streaming::event_parser::{ common::EventType, protocols::pumpswap::PumpSwapSellEvent, }; -use solana_streamer_sdk::streaming::event_parser::{Protocol, UnifiedEvent}; +use solana_streamer_sdk::streaming::event_parser::{DexEvent, Protocol}; use solana_streamer_sdk::streaming::yellowstone_grpc::{AccountFilter, TransactionFilter}; use solana_streamer_sdk::streaming::YellowstoneGrpc; -use solana_streamer_sdk::{ - match_event, streaming::event_parser::protocols::pumpswap::parser::PUMPSWAP_PROGRAM_ID, -}; use std::str::FromStr; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -170,8 +167,10 @@ async fn main() -> Result<(), Box> { let account_filter = AccountFilter { account: vec![], owner: vec![], filters: vec![] }; // listen to specific event type - let event_type_filter = - EventTypeFilter { include: vec![EventType::PumpSwapBuy, EventType::PumpSwapSell] }; + let event_type_filter = EventTypeFilter { + include: vec![EventType::PumpSwapBuy, EventType::PumpSwapSell], + ..Default::default() + }; grpc.subscribe_events_immediate( protocols, @@ -194,62 +193,59 @@ fn create_event_callback( client: Arc, blockhash_cache: BlockhashCache, selection: EventSelection, -) -> impl Fn(Box) { - move |event: Box| { - match_event!(event, { - PumpSwapBuyEvent => |e: PumpSwapBuyEvent| { - let is_wsol = e.base_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT || e.quote_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT; - let is_usdc = e.base_mint == sol_trade_sdk::constants::USDC_TOKEN_ACCOUNT || e.quote_mint == sol_trade_sdk::constants::USDC_TOKEN_ACCOUNT; - if !is_wsol && !is_usdc { - return; - } - if !selection.matches(e.pool, e.base_mint, e.quote_mint, e.metadata.recv_us) { - return; - } - // Test code, only test one transaction - if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { - let event_clone = e.clone(); - let client = client.clone(); - let blockhash_cache = blockhash_cache.clone(); - tokio::spawn(async move { - if let Err(err) = pumpswap_trade_with_grpc_buy_event( - client, - blockhash_cache, - event_clone, - ).await { - eprintln!("Error in trade: {:?}", err); - std::process::exit(1); - } - }); - } - }, - PumpSwapSellEvent => |e: PumpSwapSellEvent| { - let is_wsol = e.base_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT || e.quote_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT; - let is_usdc = e.base_mint == sol_trade_sdk::constants::USDC_TOKEN_ACCOUNT || e.quote_mint == sol_trade_sdk::constants::USDC_TOKEN_ACCOUNT; - if !is_wsol && !is_usdc { - return; - } - if !selection.matches(e.pool, e.base_mint, e.quote_mint, e.metadata.recv_us) { - return; - } - // Test code, only test one transaction - if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { - let event_clone = e.clone(); - let client = client.clone(); - let blockhash_cache = blockhash_cache.clone(); - tokio::spawn(async move { - if let Err(err) = pumpswap_trade_with_grpc_sell_event( - client, - blockhash_cache, - event_clone, - ).await { - eprintln!("Error in trade: {:?}", err); - std::process::exit(1); - } - }); - } +) -> impl Fn(DexEvent) { + move |event: DexEvent| match event { + DexEvent::PumpSwapBuyEvent(e) => { + let is_wsol = e.base_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT + || e.quote_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT; + let is_usdc = e.base_mint == sol_trade_sdk::constants::USDC_TOKEN_ACCOUNT + || e.quote_mint == sol_trade_sdk::constants::USDC_TOKEN_ACCOUNT; + if !is_wsol && !is_usdc { + return; } - }); + if !selection.matches(e.pool, e.base_mint, e.quote_mint, e.metadata.recv_us) { + return; + } + // Test code, only test one transaction + if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { + let client = client.clone(); + let blockhash_cache = blockhash_cache.clone(); + tokio::spawn(async move { + if let Err(err) = + pumpswap_trade_with_grpc_buy_event(client, blockhash_cache, e).await + { + eprintln!("Error in trade: {:?}", err); + std::process::exit(1); + } + }); + } + } + DexEvent::PumpSwapSellEvent(e) => { + let is_wsol = e.base_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT + || e.quote_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT; + let is_usdc = e.base_mint == sol_trade_sdk::constants::USDC_TOKEN_ACCOUNT + || e.quote_mint == sol_trade_sdk::constants::USDC_TOKEN_ACCOUNT; + if !is_wsol && !is_usdc { + return; + } + if !selection.matches(e.pool, e.base_mint, e.quote_mint, e.metadata.recv_us) { + return; + } + // Test code, only test one transaction + if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { + let client = client.clone(); + let blockhash_cache = blockhash_cache.clone(); + tokio::spawn(async move { + if let Err(err) = + pumpswap_trade_with_grpc_sell_event(client, blockhash_cache, e).await + { + eprintln!("Error in trade: {:?}", err); + std::process::exit(1); + } + }); + } + } + _ => {} } } @@ -280,10 +276,6 @@ async fn pumpswap_trade_with_grpc_buy_event( blockhash_cache: BlockhashCache, trade_info: PumpSwapBuyEvent, ) -> AnyResult<()> { - // solana-streamer-sdk 0.5.0 predates the appended event field. Read the - // Pool value so this compatibility example still prices effective reserves. - let virtual_quote_reserves = - fetch_pool(&client.infrastructure.rpc, &trade_info.pool).await?.virtual_quote_reserves; let params = PumpSwapParams::from_trade_with_fee_basis_points( trade_info.pool, trade_info.base_mint, @@ -292,7 +284,7 @@ async fn pumpswap_trade_with_grpc_buy_event( trade_info.pool_quote_token_account, trade_info.pool_base_token_reserves, trade_info.pool_quote_token_reserves, - virtual_quote_reserves, + trade_info.virtual_quote_reserves, trade_info.coin_creator_vault_ata, trade_info.coin_creator_vault_authority, trade_info.base_token_program, @@ -300,8 +292,8 @@ async fn pumpswap_trade_with_grpc_buy_event( trade_info.protocol_fee_recipient, Pubkey::default(), trade_info.coin_creator, - false, - 0, + trade_info.cashback_fee_basis_points != 0 || trade_info.cashback != 0, + trade_info.cashback_fee_basis_points, trade_info.lp_fee_basis_points, trade_info.protocol_fee_basis_points, trade_info.coin_creator_fee_basis_points, @@ -323,8 +315,6 @@ async fn pumpswap_trade_with_grpc_sell_event( blockhash_cache: BlockhashCache, trade_info: PumpSwapSellEvent, ) -> AnyResult<()> { - let virtual_quote_reserves = - fetch_pool(&client.infrastructure.rpc, &trade_info.pool).await?.virtual_quote_reserves; let params = PumpSwapParams::from_trade_with_fee_basis_points( trade_info.pool, trade_info.base_mint, @@ -333,7 +323,7 @@ async fn pumpswap_trade_with_grpc_sell_event( trade_info.pool_quote_token_account, trade_info.pool_base_token_reserves, trade_info.pool_quote_token_reserves, - virtual_quote_reserves, + trade_info.virtual_quote_reserves, trade_info.coin_creator_vault_ata, trade_info.coin_creator_vault_authority, trade_info.base_token_program, @@ -341,8 +331,8 @@ async fn pumpswap_trade_with_grpc_sell_event( trade_info.protocol_fee_recipient, Pubkey::default(), trade_info.coin_creator, - false, - 0, + trade_info.cashback_fee_basis_points != 0 || trade_info.cashback != 0, + trade_info.cashback_fee_basis_points, trade_info.lp_fee_basis_points, trade_info.protocol_fee_basis_points, trade_info.coin_creator_fee_basis_points, diff --git a/patches/solana-keypair/Cargo.toml b/patches/solana-keypair/Cargo.toml new file mode 100644 index 0000000..4447fb2 --- /dev/null +++ b/patches/solana-keypair/Cargo.toml @@ -0,0 +1,97 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.81.0" +name = "solana-keypair" +version = "3.1.2" +authors = ["Anza Maintainers "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Concrete implementation of a Solana `Signer`." +homepage = "https://anza.xyz/" +documentation = "https://docs.rs/solana-keypair" +readme = false +license = "Apache-2.0" +repository = "https://github.com/anza-xyz/solana-sdk" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] +all-features = true +rustdoc-args = ["--cfg=docsrs"] + +[features] +seed-derivable = [ + "dep:solana-derivation-path", + "dep:solana-seed-derivable", + "dep:ed25519-dalek-bip32", +] + +[lib] +name = "solana_keypair" +path = "src/lib.rs" + +[dependencies.ed25519-dalek] +version = "2.1.1" +features = ["rand_core"] + +[dependencies.ed25519-dalek-bip32] +version = "0.3.0" +optional = true + +[dependencies.five8] +version = "1.0.0" + +[dependencies.five8_core] +version = "1.0.0" + +[dependencies.rand] +version = "0.9.2" + +[dependencies.solana-address] +version = "2.2.0" +features = ["decode"] + +[dependencies.solana-derivation-path] +version = "3.0.0" +optional = true + +[dependencies.solana-seed-derivable] +version = "3.0.0" +optional = true + +[dependencies.solana-seed-phrase] +version = "3.0.0" + +[dependencies.solana-signature] +version = "3.3.0" +features = [ + "std", + "verify", +] +default-features = false + +[dependencies.solana-signer] +version = "3.0.0" + +[dev-dependencies.serde_json] +version = "1.0.139" + +[dev-dependencies.static_assertions] +version = "1.1.0" + +[dev-dependencies.tiny-bip39] +version = "2.0.0" diff --git a/patches/solana-keypair/src/lib.rs b/patches/solana-keypair/src/lib.rs new file mode 100644 index 0000000..5ee5d76 --- /dev/null +++ b/patches/solana-keypair/src/lib.rs @@ -0,0 +1,444 @@ +//! Concrete implementation of a Solana `Signer` from raw bytes +#![cfg_attr(docsrs, feature(doc_cfg))] +use { + ed25519_dalek::Signer as DalekSigner, + solana_seed_phrase::generate_seed_from_seed_phrase_and_passphrase, + solana_signer::SignerError, + std::{ + error, + io::{Read, Write}, + path::Path, + }, +}; +pub use { + solana_address::Address, + solana_signature::{error::Error as SignatureError, Signature}, + solana_signer::{EncodableKey, EncodableKeypair, Signer}, +}; + +#[cfg(feature = "seed-derivable")] +pub mod seed_derivable; +pub mod signable; + +/// A vanilla Ed25519 key pair +#[derive(Debug)] +pub struct Keypair(ed25519_dalek::SigningKey); + +pub const KEYPAIR_LENGTH: usize = 64; + +impl Keypair { + /// Can be used for generating a Keypair without a dependency on `rand` types + pub const SECRET_KEY_LENGTH: usize = 32; + + /// Constructs a new, random `Keypair` using `OsRng` + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + let secret_bytes = rand::random::<[u8; Self::SECRET_KEY_LENGTH]>(); + Self(ed25519_dalek::SigningKey::from_bytes(&secret_bytes)) + } + + /// Constructs a new `Keypair` using secret key bytes + pub fn new_from_array(secret_key: [u8; 32]) -> Self { + Self(ed25519_dalek::SigningKey::from(secret_key)) + } + + /// Returns this `Keypair` as a byte array + pub fn to_bytes(&self) -> [u8; KEYPAIR_LENGTH] { + self.0.to_keypair_bytes() + } + + /// Recovers a `Keypair` from a base58-encoded string + pub fn try_from_base58_string(s: &str) -> Result { + let mut buf = [0u8; ed25519_dalek::KEYPAIR_LENGTH]; + // Mixed dependency graphs may resolve five8 1.x against five8_core 0.1.x, + // whose DecodeError does not implement std::error::Error. + five8::decode_64(s, &mut buf).map_err(|e| { + SignatureError::from_source(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("base58 decode keypair: {e:?}"), + )) + })?; + Self::try_from(&buf[..]) + } + + /// Recovers a `Keypair` from a base58-encoded string + /// + /// # Panics + /// + /// Panics if given a malformed base58 string, or if the contents of the + /// encoded string is invalid Keypair data. + pub fn from_base58_string(s: &str) -> Self { + Self::try_from_base58_string(s).unwrap() + } + + /// Returns this `Keypair` as a base58-encoded string + pub fn to_base58_string(&self) -> String { + let mut out = [0u8; five8::BASE58_ENCODED_64_MAX_LEN]; + let len = five8::encode_64(&self.to_bytes(), &mut out); + unsafe { String::from_utf8_unchecked(out[..len as usize].to_vec()) } + } + + /// Gets this `Keypair`'s secret key bytes + pub fn secret_bytes(&self) -> &[u8; Self::SECRET_KEY_LENGTH] { + self.0.as_bytes() + } + + /// Allows Keypair cloning + /// + /// Note that the `Clone` trait is intentionally unimplemented because making a + /// second copy of sensitive secret keys in memory is usually a bad idea. + /// + /// Only use this in tests or when strictly required. Consider using [`std::sync::Arc`] + /// instead. + pub fn insecure_clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl TryFrom<&[u8]> for Keypair { + type Error = SignatureError; + + fn try_from(bytes: &[u8]) -> Result { + let keypair_bytes: &[u8; ed25519_dalek::KEYPAIR_LENGTH] = + bytes.try_into().map_err(|_| { + SignatureError::from_source(String::from( + "candidate keypair byte array is the wrong length", + )) + })?; + ed25519_dalek::SigningKey::from_keypair_bytes(keypair_bytes) + .map_err(|_| { + SignatureError::from_source(String::from( + "keypair bytes do not specify same pubkey as derived from their secret key", + )) + }) + .map(Self) + } +} + +#[cfg(test)] +static_assertions::const_assert_eq!(Keypair::SECRET_KEY_LENGTH, ed25519_dalek::SECRET_KEY_LENGTH); + +impl Signer for Keypair { + #[inline] + fn pubkey(&self) -> Address { + Address::from(self.0.verifying_key().to_bytes()) + } + + fn try_pubkey(&self) -> Result { + Ok(self.pubkey()) + } + + fn sign_message(&self, message: &[u8]) -> Signature { + Signature::from(self.0.sign(message).to_bytes()) + } + + fn try_sign_message(&self, message: &[u8]) -> Result { + Ok(self.sign_message(message)) + } + + fn is_interactive(&self) -> bool { + false + } +} + +impl PartialEq for Keypair +where + T: Signer, +{ + fn eq(&self, other: &T) -> bool { + self.pubkey() == other.pubkey() + } +} + +impl EncodableKey for Keypair { + fn read(reader: &mut R) -> Result> { + read_keypair(reader) + } + + fn write(&self, writer: &mut W) -> Result> { + write_keypair(self, writer) + } +} + +impl EncodableKeypair for Keypair { + type Pubkey = Address; + + /// Returns the associated pubkey. Use this function specifically for settings that involve + /// reading or writing pubkeys. For other settings, use `Signer::pubkey()` instead. + fn encodable_pubkey(&self) -> Self::Pubkey { + self.pubkey() + } +} + +/// Reads a JSON-encoded `Keypair` from a `Reader` implementor +pub fn read_keypair(reader: &mut R) -> Result> { + let mut buffer = String::new(); + reader.read_to_string(&mut buffer)?; + let trimmed = buffer.trim(); + if !trimmed.starts_with('[') || !trimmed.ends_with(']') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Input must be a JSON array", + ) + .into()); + } + // we already checked that the string has at least two chars, + // so 1..trimmed.len() - 1 won't be out of bounds + #[allow(clippy::arithmetic_side_effects)] + let contents = &trimmed[1..trimmed.len() - 1]; + let elements_vec: Vec<&str> = contents.split(',').map(|s| s.trim()).collect(); + let len = elements_vec.len(); + let elements: [&str; ed25519_dalek::KEYPAIR_LENGTH] = + elements_vec.try_into().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Expected {} elements, found {}", + ed25519_dalek::KEYPAIR_LENGTH, + len + ), + ) + })?; + let mut out = [0u8; ed25519_dalek::KEYPAIR_LENGTH]; + for (idx, element) in elements.into_iter().enumerate() { + let parsed: u8 = element.parse()?; + out[idx] = parsed; + } + Keypair::try_from(&out[..]).map_err(|e| std::io::Error::other(e.to_string()).into()) +} + +/// Reads a `Keypair` from a file +pub fn read_keypair_file>(path: F) -> Result> { + Keypair::read_from_file(path) +} + +/// Writes a `Keypair` to a `Write` implementor with JSON-encoding +pub fn write_keypair( + keypair: &Keypair, + writer: &mut W, +) -> Result> { + let keypair_bytes = keypair.to_bytes(); + let mut result = Vec::with_capacity(64 * 4 + 2); // Estimate capacity: 64 numbers * (up to 3 digits + 1 comma) + 2 brackets + + result.push(b'['); // Opening bracket + + for (i, &num) in keypair_bytes.iter().enumerate() { + if i > 0 { + result.push(b','); // Comma separator for all elements except the first + } + + // Convert number to string and then to bytes + let num_str = num.to_string(); + result.extend_from_slice(num_str.as_bytes()); + } + + result.push(b']'); // Closing bracket + writer.write_all(&result)?; + let as_string = String::from_utf8(result)?; + Ok(as_string) +} + +/// Writes a `Keypair` to a file with JSON-encoding +pub fn write_keypair_file>( + keypair: &Keypair, + outfile: F, +) -> Result> { + keypair.write_to_file(outfile) +} + +/// Constructs a `Keypair` from caller-provided seed entropy +pub fn keypair_from_seed(seed: &[u8]) -> Result> { + if seed.len() < ed25519_dalek::SECRET_KEY_LENGTH { + return Err("Seed is too short".into()); + } + // this won't fail as we've already checked the length + let secret_key = ed25519_dalek::SecretKey::try_from(&seed[..ed25519_dalek::SECRET_KEY_LENGTH])?; + Ok(Keypair(ed25519_dalek::SigningKey::from(secret_key))) +} + +pub fn keypair_from_seed_phrase_and_passphrase( + seed_phrase: &str, + passphrase: &str, +) -> Result> { + keypair_from_seed(&generate_seed_from_seed_phrase_and_passphrase( + seed_phrase, + passphrase, + )) +} + +#[cfg(test)] +mod tests { + use { + super::*, + bip39::{Language, Mnemonic, MnemonicType, Seed}, + solana_signer::unique_signers, + std::{ + fs::{self, File}, + mem, + }, + }; + + fn tmp_file_path(name: &str) -> String { + use std::env; + let out_dir = env::var("FARF_DIR").unwrap_or_else(|_| "farf".to_string()); + let keypair = Keypair::new(); + + format!("{}/tmp/{}-{}", out_dir, name, keypair.pubkey()) + } + + #[test] + fn test_write_keypair_file() { + let outfile = tmp_file_path("test_write_keypair_file.json"); + let serialized_keypair = write_keypair_file(&Keypair::new(), &outfile).unwrap(); + let keypair_vec: Vec = serde_json::from_str(&serialized_keypair).unwrap(); + assert!(Path::new(&outfile).exists()); + assert_eq!( + keypair_vec, + read_keypair_file(&outfile).unwrap().to_bytes().to_vec() + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + File::open(&outfile) + .expect("open") + .metadata() + .expect("metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + + assert_eq!( + read_keypair_file(&outfile).unwrap().pubkey().as_ref().len(), + mem::size_of::
() + ); + fs::remove_file(&outfile).unwrap(); + assert!(!Path::new(&outfile).exists()); + } + + #[test] + fn test_write_keypair_file_overwrite_ok() { + let outfile = tmp_file_path("test_write_keypair_file_overwrite_ok.json"); + + write_keypair_file(&Keypair::new(), &outfile).unwrap(); + write_keypair_file(&Keypair::new(), &outfile).unwrap(); + } + + #[test] + fn test_write_keypair_file_truncate() { + let outfile = tmp_file_path("test_write_keypair_file_truncate.json"); + + write_keypair_file(&Keypair::new(), &outfile).unwrap(); + read_keypair_file(&outfile).unwrap(); + + // Ensure outfile is truncated + { + let mut f = File::create(&outfile).unwrap(); + f.write_all(String::from_utf8([b'a'; 2048].to_vec()).unwrap().as_bytes()) + .unwrap(); + } + write_keypair_file(&Keypair::new(), &outfile).unwrap(); + read_keypair_file(&outfile).unwrap(); + } + + #[test] + fn test_keypair_from_seed() { + let good_seed = vec![0; 32]; + assert!(keypair_from_seed(&good_seed).is_ok()); + + let too_short_seed = vec![0; 31]; + assert!(keypair_from_seed(&too_short_seed).is_err()); + } + + #[test] + fn test_keypair() { + let keypair = keypair_from_seed(&[0u8; 32]).unwrap(); + let pubkey = keypair.pubkey(); + let data = [1u8]; + let sig = keypair.sign_message(&data); + + // Signer + assert_eq!(keypair.try_pubkey().unwrap(), pubkey); + assert_eq!(keypair.pubkey(), pubkey); + assert_eq!(keypair.try_sign_message(&data).unwrap(), sig); + assert_eq!(keypair.sign_message(&data), sig); + + // PartialEq + let keypair2 = keypair_from_seed(&[0u8; 32]).unwrap(); + assert_eq!(keypair, keypair2); + } + + fn pubkeys(signers: &[&dyn Signer]) -> Vec
{ + signers.iter().map(|x| x.pubkey()).collect() + } + + #[test] + fn test_unique_signers() { + let alice = Keypair::new(); + let bob = Keypair::new(); + assert_eq!( + pubkeys(&unique_signers(vec![&alice, &bob, &alice])), + pubkeys(&[&alice, &bob]) + ); + } + + #[test] + fn test_containers() { + use std::{rc::Rc, sync::Arc}; + + struct Foo { + #[allow(unused)] + signer: S, + } + + fn foo(_s: impl Signer) {} + + let _arc_signer = Foo { + signer: Arc::new(Keypair::new()), + }; + foo(Arc::new(Keypair::new())); + + let _rc_signer = Foo { + signer: Rc::new(Keypair::new()), + }; + foo(Rc::new(Keypair::new())); + + let _ref_signer = Foo { + signer: &Keypair::new(), + }; + foo(Keypair::new()); + + let _box_signer = Foo { + signer: Box::new(Keypair::new()), + }; + foo(Box::new(Keypair::new())); + + let _signer = Foo { + signer: Keypair::new(), + }; + foo(Keypair::new()); + } + + #[test] + fn test_keypair_from_seed_phrase_and_passphrase() { + let mnemonic = Mnemonic::new(MnemonicType::Words12, Language::English); + let passphrase = "42"; + let seed = Seed::new(&mnemonic, passphrase); + let expected_keypair = keypair_from_seed(seed.as_bytes()).unwrap(); + let keypair = + keypair_from_seed_phrase_and_passphrase(mnemonic.phrase(), passphrase).unwrap(); + assert_eq!(keypair.pubkey(), expected_keypair.pubkey()); + } + + #[test] + fn test_base58() { + let keypair = keypair_from_seed(&[0u8; 32]).unwrap(); + let as_base58 = keypair.to_base58_string(); + let parsed = Keypair::from_base58_string(&as_base58); + assert_eq!(keypair, parsed); + } +} diff --git a/patches/solana-keypair/src/seed_derivable.rs b/patches/solana-keypair/src/seed_derivable.rs new file mode 100644 index 0000000..b211e3a --- /dev/null +++ b/patches/solana-keypair/src/seed_derivable.rs @@ -0,0 +1,49 @@ +//! Implementation of the SeedDerivable trait for Keypair + +use { + crate::{keypair_from_seed, keypair_from_seed_phrase_and_passphrase, Keypair}, + ed25519_dalek_bip32::Error as Bip32Error, + solana_derivation_path::DerivationPath, + solana_seed_derivable::SeedDerivable, + std::error, +}; + +impl SeedDerivable for Keypair { + fn from_seed(seed: &[u8]) -> Result> { + keypair_from_seed(seed) + } + + fn from_seed_and_derivation_path( + seed: &[u8], + derivation_path: Option, + ) -> Result> { + keypair_from_seed_and_derivation_path(seed, derivation_path) + } + + fn from_seed_phrase_and_passphrase( + seed_phrase: &str, + passphrase: &str, + ) -> Result> { + keypair_from_seed_phrase_and_passphrase(seed_phrase, passphrase) + } +} + +/// Generates a Keypair using Bip32 Hierarchical Derivation if derivation-path is provided; +/// otherwise generates the base Bip44 Solana keypair from the seed +pub fn keypair_from_seed_and_derivation_path( + seed: &[u8], + derivation_path: Option, +) -> Result> { + let derivation_path = derivation_path.unwrap_or_default(); + bip32_derived_keypair(seed, derivation_path).map_err(|err| err.to_string().into()) +} + +/// Generates a Keypair using Bip32 Hierarchical Derivation +fn bip32_derived_keypair( + seed: &[u8], + derivation_path: DerivationPath, +) -> Result { + let extended = ed25519_dalek_bip32::ExtendedSigningKey::from_seed(seed) + .and_then(|extended| extended.derive(&derivation_path))?; + Ok(Keypair(extended.signing_key)) +} diff --git a/patches/solana-keypair/src/signable.rs b/patches/solana-keypair/src/signable.rs new file mode 100644 index 0000000..1531e0b --- /dev/null +++ b/patches/solana-keypair/src/signable.rs @@ -0,0 +1,23 @@ +use { + crate::Keypair, + solana_address::Address, + solana_signature::Signature, + solana_signer::Signer, + std::borrow::{Borrow, Cow}, +}; + +pub trait Signable { + fn sign(&mut self, keypair: &Keypair) { + let signature = keypair.sign_message(self.signable_data().borrow()); + self.set_signature(signature); + } + fn verify(&self) -> bool { + self.get_signature() + .verify(self.pubkey().as_ref(), self.signable_data().borrow()) + } + + fn pubkey(&self) -> Address; + fn signable_data(&self) -> Cow<'_, [u8]>; + fn get_signature(&self) -> Signature; + fn set_signature(&mut self, signature: Signature); +} diff --git a/src/instruction/pumpswap.rs b/src/instruction/pumpswap.rs index 943190f..13dddaa 100755 --- a/src/instruction/pumpswap.rs +++ b/src/instruction/pumpswap.rs @@ -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(¶ms).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(¶ms).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(); diff --git a/src/instruction/utils/pumpswap.rs b/src/instruction/utils/pumpswap.rs index 6602f6a..65a88a8 100644 --- a/src/instruction/utils/pumpswap.rs +++ b/src/instruction/utils/pumpswap.rs @@ -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); + } } diff --git a/src/instruction/utils/pumpswap_types.rs b/src/instruction/utils/pumpswap_types.rs index 06bcc7d..853c290 100644 --- a/src/instruction/utils/pumpswap_types.rs +++ b/src/instruction/utils/pumpswap_types.rs @@ -78,7 +78,7 @@ pub fn pool_decode(data: &[u8]) -> Option { /// 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); } diff --git a/src/utils/calc/pumpswap.rs b/src/utils/calc/pumpswap.rs index 82ded0b..1872cb3 100644 --- a/src/utils/calc/pumpswap.rs +++ b/src/utils/calc/pumpswap.rs @@ -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 { + 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 { @@ -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 { 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 { @@ -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 { 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 { @@ -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 { 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 { + 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 { @@ -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 { 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."); + } +}