From dd41dd4f8737b08285ca46189f38e4fd0c0baa50 Mon Sep 17 00:00:00 2001 From: 0xfnzero <0xfnzero@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:58:56 +0800 Subject: [PATCH 1/3] feat(pumpswap): support virtual quote reserves --- Cargo.toml | 2 +- README.md | 16 +- README_CN.md | 16 +- examples/pumpswap_trading/README.md | 1 + examples/pumpswap_trading/README_CN.md | 1 + examples/pumpswap_trading/src/main.rs | 9 + idl/pump_amm.json | 742 ++++++++++++++++++++++++ src/instruction/pumpswap.rs | 36 +- src/instruction/utils/pumpswap.rs | 30 +- src/instruction/utils/pumpswap_types.rs | 119 +++- src/trading/core/params/pumpswap.rs | 40 +- 11 files changed, 976 insertions(+), 36 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b1f94b1..643980d 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sol-trade-sdk" -version = "4.0.22" +version = "4.0.23" edition = "2021" authors = [ "William ", diff --git a/README.md b/README.md index d636d81..d9415fb 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ This SDK is available in multiple languages: ## 🔖 Current Release -**Rust crate:** `sol-trade-sdk = "4.0.21"` +**Rust crate:** `sol-trade-sdk = "4.0.23"` -This release refreshes PumpFun native-SOL quote handling so SOL/WSOL sentinels prefer the smaller V1 hot path, keeps the default RPC submit lane active alongside SWQoS lanes, restores the fast-submit result window to 5 seconds, and aligns Raydium CPMM fixed-output swaps with the on-chain `swap_base_out` instruction. Trade execution requires a caller-supplied `recent_blockhash` or durable nonce; hot-path execution does not query RPC for blockhash, account, or balance data. +This release updates PumpSwap for the July 2026 virtual quote reserve rollout. Pool and event schemas include `virtual_quote_reserves`, and all PumpSwap buy, sell, pricing, and dynamic-fee calculations use `quote_vault_balance + virtual_quote_reserves`. ## ✨ Features @@ -126,14 +126,14 @@ Add the dependency to your `Cargo.toml`: ```toml # Add to your Cargo.toml -sol-trade-sdk = { path = "./sol-trade-sdk", version = "4.0.21" } +sol-trade-sdk = { path = "./sol-trade-sdk", version = "4.0.23" } ``` ### Use crates.io ```toml # Add to your Cargo.toml -sol-trade-sdk = "4.0.21" +sol-trade-sdk = "4.0.23" ``` ## 🛠️ Usage Examples @@ -506,6 +506,14 @@ For **PumpSwap** (Pump AMM), `coin_creator_vault_ata` and `coin_creator_vault_au - **sol-parser-sdk**: Instruction parser sets them from accounts 17 and 18; the account filler also fills them when the event comes from logs. Use `PumpSwapParams::from_trade(..., e.coin_creator_vault_ata, e.coin_creator_vault_authority, ...)` with the buy/sell event `e`. - **solana-streamer**: Instruction parser sets them from `accounts.get(17)` and `accounts.get(18)`. Use the same `from_trade` with the event's `coin_creator_vault_ata` and `coin_creator_vault_authority`. +#### PumpSwap: virtual quote reserves + +PumpSwap quotes must use `effective_quote_reserves = pool_quote_token_account.amount + virtual_quote_reserves`. The Pool account and BuyEvent/SellEvent encode `virtual_quote_reserves` as `i128`. + +- RPC constructors such as `PumpSwapParams::from_pool_address_by_rpc` read and apply the Pool field automatically. +- Event fast paths must pass the event's raw `pool_quote_token_reserves` and `virtual_quote_reserves` separately to `PumpSwapParams::from_trade(...)` or `from_trade_with_fee_basis_points(...)`. Do not add them before calling the constructor. +- The SDK uses effective reserves for buys, sells, prices, and dynamic fee-tier selection. Invalid signed sums return an error instead of wrapping. + ## 🛡️ MEV Protection Services You can apply for a key through the official website: [Community Website](https://fnzero.dev/swqos) diff --git a/README_CN.md b/README_CN.md index 91ea4ed..5f54526 100755 --- a/README_CN.md +++ b/README_CN.md @@ -92,9 +92,9 @@ ## 🔖 当前版本 -**Rust crate:** `sol-trade-sdk = "4.0.21"` +**Rust crate:** `sol-trade-sdk = "4.0.23"` -本版本刷新 PumpFun native SOL quote 处理逻辑,SOL/WSOL sentinel 默认优先走更小的 V1 热路径,确保默认 RPC 提交通道会和 SWQoS 通道一起发出,快速提交结果等待窗口恢复为 5 秒,并将 Raydium CPMM fixed-output 交易对齐到链上 `swap_base_out` 指令。交易执行必须由调用方传入 `recent_blockhash` 或 durable nonce;热路径不会查询 RPC 获取 blockhash、账户或余额数据。 +本版本适配 PumpSwap 2026 年 7 月的虚拟 quote 储备升级。Pool 与事件 schema 新增 `virtual_quote_reserves`,PumpSwap 买入、卖出、报价和动态费率计算统一使用 `quote_vault_balance + virtual_quote_reserves`。 ## ✨ 项目特性 @@ -126,14 +126,14 @@ git clone https://github.com/0xfnzero/sol-trade-sdk ```toml # 添加到您的 Cargo.toml -sol-trade-sdk = { path = "./sol-trade-sdk", version = "4.0.21" } +sol-trade-sdk = { path = "./sol-trade-sdk", version = "4.0.23" } ``` ### 使用 crates.io ```toml # 添加到您的 Cargo.toml -sol-trade-sdk = "4.0.21" +sol-trade-sdk = "4.0.23" ``` ## 🛠️ 使用示例 @@ -503,6 +503,14 @@ legacy SOL 事件里如果 `quote_mint` 是默认值或 Solscan SOL,并且 quo | 未设置(默认)/ `SOL_TOKEN_ACCOUNT` (`So111...11111`) / `WSOL_TOKEN_ACCOUNT` (`So111...11112`) | 优先旧版 `buy`/`sell`/`buy_exact_sol_in` | native SOL 配对;普通 SOL 结算走 V1,显式 WSOL 结算才走 V2 | | `USDC_TOKEN_ACCOUNT` | `buy_v2`/`sell_v2`/`buy_exact_quote_in_v2` | USDC 配对(必须使用 v2) | +#### PumpSwap:虚拟 quote 储备 + +PumpSwap 报价必须使用 `effective_quote_reserves = pool_quote_token_account.amount + virtual_quote_reserves`。Pool 账户以及 BuyEvent/SellEvent 中的 `virtual_quote_reserves` 类型均为 `i128`。 + +- `PumpSwapParams::from_pool_address_by_rpc` 等 RPC 构造器会自动读取并应用 Pool 字段。 +- 事件热路径必须把事件中的原始 `pool_quote_token_reserves` 和 `virtual_quote_reserves` 分别传给 `PumpSwapParams::from_trade(...)` 或 `from_trade_with_fee_basis_points(...)`,不要在调用前自行相加。 +- SDK 在买入、卖出、报价和动态费率分层中统一使用有效储备;无效的有符号结果会返回错误,不会发生整数回绕。 + ## 🛡️ MEV 保护服务 可以通过官网申请密钥:[社区官网](https://fnzero.dev/swqos) diff --git a/examples/pumpswap_trading/README.md b/examples/pumpswap_trading/README.md index ac58549..b68d5b0 100644 --- a/examples/pumpswap_trading/README.md +++ b/examples/pumpswap_trading/README.md @@ -21,6 +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 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 c799c83..1a879b6 100644 --- a/examples/pumpswap_trading/README_CN.md +++ b/examples/pumpswap_trading/README_CN.md @@ -19,6 +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。 - 示例记录买前余额,只卖出确认后的余额增量;卖出前重新获取池状态和 blockhash。 - 若业务必须精确花费 quote,应改用 `BuyAmount::ExactInput`。这会启用最小输出保护,在活跃池中更容易因状态变化而失败。 diff --git a/examples/pumpswap_trading/src/main.rs b/examples/pumpswap_trading/src/main.rs index 6fde037..37c1759 100644 --- a/examples/pumpswap_trading/src/main.rs +++ b/examples/pumpswap_trading/src/main.rs @@ -1,4 +1,5 @@ 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, @@ -279,6 +280,10 @@ 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, @@ -287,6 +292,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.coin_creator_vault_ata, trade_info.coin_creator_vault_authority, trade_info.base_token_program, @@ -317,6 +323,8 @@ 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, @@ -325,6 +333,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.coin_creator_vault_ata, trade_info.coin_creator_vault_authority, trade_info.base_token_program, diff --git a/idl/pump_amm.json b/idl/pump_amm.json index f73cf11..a654b6f 100644 --- a/idl/pump_amm.json +++ b/idl/pump_amm.json @@ -288,6 +288,213 @@ } ] }, + { + "name": "boost_buy_and_burn", + "discriminator": [ + 105, + 68, + 6, + 175, + 0, + 7, + 35, + 162 + ], + "accounts": [ + { + "name": "pool" + }, + { + "name": "authority", + "writable": true, + "signer": true + }, + { + "name": "global_config", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + } + ] + } + }, + { + "name": "base_mint", + "writable": true, + "relations": [ + "pool" + ] + }, + { + "name": "quote_mint", + "relations": [ + "pool" + ] + }, + { + "name": "pool_base_token_account", + "writable": true, + "relations": [ + "pool" + ] + }, + { + "name": "pool_quote_token_account", + "writable": true, + "relations": [ + "pool" + ] + }, + { + "name": "boost_vault_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 98, + 111, + 111, + 115, + 116, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "pool" + } + ] + } + }, + { + "name": "boost_vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "boost_vault_authority" + }, + { + "kind": "account", + "path": "quote_token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "base_token_program" + }, + { + "name": "quote_token_program" + }, + { + "name": "event_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 95, + 95, + 101, + 118, + 101, + 110, + 116, + 95, + 97, + 117, + 116, + 104, + 111, + 114, + 105, + 116, + 121 + ] + } + ] + } + }, + { + "name": "program" + } + ], + "args": [ + { + "name": "quote_amount_in", + "type": "u64" + }, + { + "name": "min_base_amount_burned", + "type": "u64" + } + ] + }, { "name": "buy", "docs": [ @@ -2587,6 +2794,208 @@ ], "args": [] }, + { + "name": "init_boost", + "discriminator": [ + 140, + 233, + 33, + 94, + 132, + 90, + 194, + 143 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "global_config", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108, + 95, + 99, + 111, + 110, + 102, + 105, + 103 + ] + } + ] + } + }, + { + "name": "creator", + "writable": true, + "signer": true + }, + { + "name": "base_mint", + "relations": [ + "pool" + ] + }, + { + "name": "quote_mint", + "relations": [ + "pool" + ] + }, + { + "name": "pool_base_token_account", + "relations": [ + "pool" + ] + }, + { + "name": "pool_quote_token_account", + "writable": true, + "relations": [ + "pool" + ] + }, + { + "name": "boost_vault_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 98, + 111, + 111, + 115, + 116, + 95, + 118, + 97, + 117, + 108, + 116 + ] + }, + { + "kind": "account", + "path": "pool" + } + ] + } + }, + { + "name": "boost_vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "account", + "path": "boost_vault_authority" + }, + { + "kind": "account", + "path": "quote_token_program" + }, + { + "kind": "account", + "path": "quote_mint" + } + ], + "program": { + "kind": "const", + "value": [ + 140, + 151, + 37, + 143, + 78, + 36, + 137, + 241, + 187, + 61, + 16, + 41, + 20, + 142, + 13, + 131, + 11, + 90, + 19, + 153, + 218, + 255, + 16, + 132, + 4, + 142, + 123, + 216, + 219, + 233, + 248, + 89 + ] + } + } + }, + { + "name": "quote_token_program" + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + }, + { + "name": "associated_token_program", + "address": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + }, + { + "name": "event_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 95, + 95, + 101, + 118, + 101, + 110, + 116, + 95, + 97, + 117, + 116, + 104, + 111, + 114, + 105, + 116, + 121 + ] + } + ] + } + }, + { + "name": "program" + } + ], + "args": [] + }, { "name": "init_user_volume_accumulator", "discriminator": [ @@ -3175,6 +3584,72 @@ } ] }, + { + "name": "set_boost_authority", + "discriminator": [ + 227, + 149, + 76, + 42, + 130, + 39, + 234, + 205 + ], + "accounts": [ + { + "name": "admin", + "signer": true, + "relations": [ + "global_config" + ] + }, + { + "name": "global_config", + "writable": true + }, + { + "name": "boost_authority" + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + }, + { + "name": "event_authority", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 95, + 95, + 101, + 118, + 101, + 110, + 116, + 95, + 97, + 117, + 116, + 104, + 111, + 114, + 105, + 116, + 121 + ] + } + ] + } + }, + { + "name": "program" + } + ], + "args": [] + }, { "name": "set_coin_creator", "docs": [ @@ -3610,6 +4085,38 @@ ], "args": [] }, + { + "name": "toggle_boost", + "discriminator": [ + 117, + 161, + 160, + 74, + 223, + 137, + 118, + 99 + ], + "accounts": [ + { + "name": "admin", + "signer": true, + "relations": [ + "global_config" + ] + }, + { + "name": "global_config", + "writable": true + } + ], + "args": [ + { + "name": "enabled", + "type": "bool" + } + ] + }, { "name": "toggle_cashback_enabled", "discriminator": [ @@ -4672,6 +5179,19 @@ 222 ] }, + { + "name": "BoostBuyAndBurnEvent", + "discriminator": [ + 63, + 69, + 28, + 22, + 48, + 92, + 194, + 185 + ] + }, { "name": "BuyEvent", "discriminator": [ @@ -4802,6 +5322,19 @@ 124 ] }, + { + "name": "InitBoostEvent", + "discriminator": [ + 174, + 124, + 74, + 249, + 4, + 81, + 246, + 17 + ] + }, { "name": "InitUserVolumeAccumulatorEvent", "discriminator": [ @@ -4867,6 +5400,19 @@ 211 ] }, + { + "name": "SetBoostAuthorityEvent", + "discriminator": [ + 89, + 128, + 240, + 141, + 91, + 202, + 71, + 105 + ] + }, { "name": "SetMetaplexCoinCreatorEvent", "discriminator": [ @@ -5177,6 +5723,51 @@ { "code": 6058, "name": "BuybackFeeRecipientMissing" + }, + { + "code": 6059, + "name": "MissingCashbackAccounts", + "msg": "Cashback trade is missing the required remaining accounts" + }, + { + "code": 6060, + "name": "InvalidCashbackAccumulator", + "msg": "Cashback user_volume_accumulator account is invalid" + }, + { + "code": 6061, + "name": "InvalidCashbackAccumulatorAta", + "msg": "Cashback user_volume_accumulator ATA is missing or invalid" + }, + { + "code": 6062, + "name": "InvalidPoolV2", + "msg": "pool_v2 remaining account is missing or invalid" + }, + { + "code": 6063, + "name": "InsufficientRealQuoteReserves", + "msg": "BOOST: sell output exceeds the real quote vault. effective = real + virtual is pricing-only; payout is capped at real_vault, so quote min(out, real_vault)" + }, + { + "code": 6064, + "name": "BoostPoolLiquidityUnsupported", + "msg": "BOOST: deposit/withdraw don't apply to boost pools" + }, + { + "code": 6065, + "name": "PoolCannotBoost", + "msg": "BOOST: pool cannot be boosted (no virtual reserves)" + }, + { + "code": 6066, + "name": "BoostDisabled", + "msg": "BOOST: boost is disabled" + }, + { + "code": 6067, + "name": "SeedLockViolation", + "msg": "BOOST: lp_supply must never drop below the circulating LP mint supply" } ], "types": [ @@ -5292,6 +5883,62 @@ ] } }, + { + "name": "BoostBuyAndBurnEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "timestamp", + "type": "i64" + }, + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "bonding_curve", + "type": "pubkey" + }, + { + "name": "pool", + "type": "pubkey" + }, + { + "name": "authority", + "type": "pubkey" + }, + { + "name": "quote_amount_in_requested", + "type": "u64" + }, + { + "name": "quote_amount_in_used", + "type": "u64" + }, + { + "name": "base_amount_burned", + "type": "u64" + }, + { + "name": "virtual_quote_reserves", + "type": "i128" + }, + { + "name": "real_quote_reserves_after", + "type": "u64" + }, + { + "name": "base_reserves_after", + "type": "u64" + }, + { + "name": "boost_vault_remaining", + "type": "u64" + } + ] + } + }, { "name": "BuyEvent", "docs": [ @@ -5435,6 +6082,18 @@ { "name": "buyback_fee", "type": "u64" + }, + { + "name": "virtual_quote_reserves", + "type": "i128" + }, + { + "name": "can_boost", + "type": "bool" + }, + { + "name": "base_supply", + "type": "u64" } ] } @@ -6018,6 +6677,14 @@ { "name": "buyback_basis_points", "type": "u64" + }, + { + "name": "boost_authority", + "type": "pubkey" + }, + { + "name": "boost_enabled", + "type": "bool" } ] } @@ -6064,6 +6731,38 @@ ] } }, + { + "name": "InitBoostEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "timestamp", + "type": "i64" + }, + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "bonding_curve", + "type": "pubkey" + }, + { + "name": "pool", + "type": "pubkey" + }, + { + "name": "virtual_quote_reserves", + "type": "i128" + }, + { + "name": "real_quote_reserves_after", + "type": "u64" + } + ] + } + }, { "name": "InitUserVolumeAccumulatorEvent", "type": { @@ -6180,6 +6879,13 @@ { "name": "is_cashback_coin", "type": "bool" + }, + { + "name": "virtual_quote_reserves", + "docs": [ + "For non-boost pools, value is 0, so the behavior is identical to legacy pools." + ], + "type": "i128" } ] } @@ -6321,6 +7027,18 @@ { "name": "buyback_fee", "type": "u64" + }, + { + "name": "virtual_quote_reserves", + "type": "i128" + }, + { + "name": "can_boost", + "type": "bool" + }, + { + "name": "base_supply", + "type": "u64" } ] } @@ -6353,6 +7071,30 @@ ] } }, + { + "name": "SetBoostAuthorityEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "timestamp", + "type": "i64" + }, + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "old_boost_authority", + "type": "pubkey" + }, + { + "name": "new_boost_authority", + "type": "pubkey" + } + ] + } + }, { "name": "SetMetaplexCoinCreatorEvent", "type": { diff --git a/src/instruction/pumpswap.rs b/src/instruction/pumpswap.rs index a72b816..943190f 100755 --- a/src/instruction/pumpswap.rs +++ b/src/instruction/pumpswap.rs @@ -54,7 +54,7 @@ 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.pool_quote_token_reserves; + let pool_quote_token_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; @@ -283,7 +283,7 @@ 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.pool_quote_token_reserves; + let pool_quote_token_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; @@ -535,6 +535,7 @@ mod tests { pk(4), 1_000_000_000, 2_000_000_000, + 0, pk(5), accounts::DEFAULT_COIN_CREATOR_VAULT_AUTHORITY, crate::constants::TOKEN_PROGRAM, @@ -627,6 +628,7 @@ mod tests { pk(4), 1_000_000_000, 2_000_000_000, + 0, pk(5), accounts::DEFAULT_COIN_CREATOR_VAULT_AUTHORITY, crate::constants::TOKEN_PROGRAM, @@ -653,8 +655,9 @@ mod tests { let mut params = swap_params(TradeType::Buy, None); params.input_amount = Some(1_000_000); params.use_exact_sol_amount = Some(false); - params.protocol_params = - DexParamEnum::PumpSwap(pumpswap_params().with_fee_basis_points(20, 5, 75)); + let mut protocol_params = pumpswap_params().with_fee_basis_points(20, 5, 75); + protocol_params.virtual_quote_reserves = 500_000_000; + params.protocol_params = DexParamEnum::PumpSwap(protocol_params); let instructions = PumpSwapInstructionBuilder.build_buy_instructions(¶ms).await.unwrap(); @@ -667,10 +670,33 @@ mod tests { 1_000_000, 100, 1_000_000_000, - 2_000_000_000, + 2_500_000_000, &crate::instruction::utils::pumpswap::PumpSwapFeeBasisPoints::new(20, 5, 0), ) .unwrap(); assert_eq!(base_amount_out, expected.base); } + + #[tokio::test] + async fn pumpswap_sell_prices_with_effective_quote_reserves() { + let mut params = swap_params(TradeType::Sell, None); + let mut protocol_params = pumpswap_params().with_fee_basis_points(20, 5, 0); + protocol_params.virtual_quote_reserves = 500_000_000; + params.protocol_params = DexParamEnum::PumpSwap(protocol_params); + + let instructions = + PumpSwapInstructionBuilder.build_sell_instructions(¶ms).await.unwrap(); + let ix = instructions.last().unwrap(); + let min_quote_amount_out = u64::from_le_bytes(ix.data[16..24].try_into().unwrap()); + + let expected = crate::utils::calc::pumpswap::sell_base_input_internal_with_fees( + 100_000, + 100, + 1_000_000_000, + 2_500_000_000, + &crate::instruction::utils::pumpswap::PumpSwapFeeBasisPoints::new(20, 5, 0), + ) + .unwrap(); + assert_eq!(min_quote_amount_out, expected.min_quote); + } } diff --git a/src/instruction/utils/pumpswap.rs b/src/instruction/utils/pumpswap.rs index 57e60ea..6602f6a 100644 --- a/src/instruction/utils/pumpswap.rs +++ b/src/instruction/utils/pumpswap.rs @@ -18,7 +18,7 @@ use std::sync::{ use std::time::{Duration, Instant}; use tracing::warn; -// Pool account sizes moved to find_by_base_mint/find_by_quote_mint (POOL_DATA_LEN_SPL, POOL_DATA_LEN_T22) +// Pool account sizes are handled by find_by_base_mint/find_by_quote_mint. /// Constants used as seeds for deriving PDAs (Program Derived Addresses) pub mod seeds { @@ -740,12 +740,14 @@ pub async fn fetch_pool( Ok(pool) } -/// Known pool account sizes: 252 (SPL Token) and 643 (Token2022) -const POOL_DATA_LEN_SPL: u64 = 8 + 244; -const POOL_DATA_LEN_T22: u64 = 643; +/// Known allocated Pool account sizes. The July 2026 layout carrying +/// `virtual_quote_reserves` is allocated to 300 bytes on-chain. +const POOL_DATA_LEN_LEGACY: u64 = 8 + 244; +const POOL_DATA_LEN_CURRENT: u64 = 300; +const POOL_DATA_LEN_EXTENDED: u64 = 643; -/// Run getProgramAccounts with a Memcmp filter, querying both pool sizes in parallel. -async fn get_program_accounts_both_sizes( +/// Run getProgramAccounts with a Memcmp filter, querying known Pool sizes in parallel. +async fn get_program_accounts_known_sizes( rpc: &SolanaRpcClient, memcmp_offset: usize, mint: &Pubkey, @@ -768,12 +770,14 @@ async fn get_program_accounts_both_sizes( }; let program_id = accounts::AMM_PROGRAM; #[allow(deprecated)] - let (spl_result, t22_result) = tokio::join!( - rpc.get_program_accounts_with_config(&program_id, make_config(POOL_DATA_LEN_SPL)), - rpc.get_program_accounts_with_config(&program_id, make_config(POOL_DATA_LEN_T22)), + let (legacy_result, current_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_EXTENDED)), ); - let mut all = spl_result.unwrap_or_default(); - all.extend(t22_result.unwrap_or_default()); + let mut all = legacy_result.unwrap_or_default(); + all.extend(current_result.unwrap_or_default()); + all.extend(extended_result.unwrap_or_default()); Ok(all) } @@ -797,7 +801,7 @@ pub async fn find_by_base_mint( base_mint: &Pubkey, ) -> Result<(Pubkey, Pool), anyhow::Error> { // base_mint offset: 8(discriminator) + 1(bump) + 2(index) + 32(creator) = 43 - let accounts = get_program_accounts_both_sizes(rpc, 43, base_mint).await?; + let accounts = get_program_accounts_known_sizes(rpc, 43, base_mint).await?; if accounts.is_empty() { return Err(anyhow!("No pool found for mint {}", base_mint)); } @@ -814,7 +818,7 @@ pub async fn find_by_quote_mint( quote_mint: &Pubkey, ) -> Result<(Pubkey, Pool), anyhow::Error> { // quote_mint offset: 8 + 1 + 2 + 32 + 32 = 75 - let accounts = get_program_accounts_both_sizes(rpc, 75, quote_mint).await?; + let accounts = get_program_accounts_known_sizes(rpc, 75, quote_mint).await?; if accounts.is_empty() { return Err(anyhow!("No pool found for mint {}", quote_mint)); } diff --git a/src/instruction/utils/pumpswap_types.rs b/src/instruction/utils/pumpswap_types.rs index b44eaec..06bcc7d 100644 --- a/src/instruction/utils/pumpswap_types.rs +++ b/src/instruction/utils/pumpswap_types.rs @@ -17,16 +17,121 @@ pub struct Pool { pub is_mayhem_mode: bool, /// Whether this pool's coin has cashback enabled pub is_cashback_coin: bool, - /// Reserved for future fields (pump-public-docs: pool structure = 244 bytes total) - pub _reserved: [u8; 7], + /// Virtual quote reserves appended to the Pool account. + /// + /// Quotes must use `quote_vault_balance + virtual_quote_reserves`. + pub virtual_quote_reserves: i128, } -/// Borsh 解码用的 Pool 长度。链上池为 244 字节(pump-public-docs Breaking Change),与 POOL_SIZE 一致。 -pub const POOL_SIZE: usize = 244; +/// Minimum Borsh payload length for the current Pool layout, excluding the +/// 8-byte Anchor account discriminator. +pub const POOL_SIZE: usize = 1 + 2 + 32 * 6 + 8 + 32 + 1 + 1 + 16; +const LEGACY_POOL_SIZE: usize = 1 + 2 + 32 * 6 + 8 + 32 + 1 + 1; + +#[derive(BorshDeserialize)] +struct LegacyPool { + pool_bump: u8, + index: u16, + creator: Pubkey, + base_mint: Pubkey, + quote_mint: Pubkey, + lp_mint: Pubkey, + pool_base_token_account: Pubkey, + pool_quote_token_account: Pubkey, + lp_supply: u64, + coin_creator: Pubkey, + is_mayhem_mode: bool, + is_cashback_coin: bool, +} + +impl From for Pool { + fn from(pool: LegacyPool) -> Self { + Self { + pool_bump: pool.pool_bump, + index: pool.index, + creator: pool.creator, + base_mint: pool.base_mint, + quote_mint: pool.quote_mint, + lp_mint: pool.lp_mint, + pool_base_token_account: pool.pool_base_token_account, + pool_quote_token_account: pool.pool_quote_token_account, + lp_supply: pool.lp_supply, + coin_creator: pool.coin_creator, + is_mayhem_mode: pool.is_mayhem_mode, + is_cashback_coin: pool.is_cashback_coin, + virtual_quote_reserves: 0, + } + } +} pub fn pool_decode(data: &[u8]) -> Option { - if data.len() < POOL_SIZE { - return None; + if data.len() >= POOL_SIZE { + return borsh::from_slice::(&data[..POOL_SIZE]).ok(); + } + + if data.len() >= LEGACY_POOL_SIZE { + return borsh::from_slice::(&data[..LEGACY_POOL_SIZE]).ok().map(Into::into); + } + + None +} + +/// Compute the quote reserves used by PumpSwap pricing. +/// +/// Returns `None` when the signed sum is negative or cannot fit in a `u64`. +#[inline] +pub fn effective_quote_reserves( + quote_vault_balance: u64, + virtual_quote_reserves: i128, +) -> Option { + i128::from(quote_vault_balance) + .checked_add(virtual_quote_reserves) + .and_then(|reserves| u64::try_from(reserves).ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pool_payload(virtual_quote_reserves: i128) -> Vec { + let mut data = Vec::with_capacity(POOL_SIZE); + data.push(7); + data.extend_from_slice(&42u16.to_le_bytes()); + for seed in 1..=6 { + data.extend_from_slice(Pubkey::new_from_array([seed; 32]).as_ref()); + } + data.extend_from_slice(&123_456u64.to_le_bytes()); + data.extend_from_slice(Pubkey::new_from_array([7; 32]).as_ref()); + data.push(1); + data.push(0); + data.extend_from_slice(&virtual_quote_reserves.to_le_bytes()); + data + } + + #[test] + fn decodes_current_pool_virtual_quote_reserves() { + let pool = pool_decode(&pool_payload(987_654_321)).unwrap(); + + assert_eq!(pool.virtual_quote_reserves, 987_654_321); + assert!(pool.is_mayhem_mode); + assert!(!pool.is_cashback_coin); + } + + #[test] + fn decodes_legacy_pool_with_zero_virtual_quote_reserves() { + let mut data = pool_payload(0); + data.truncate(LEGACY_POOL_SIZE); + data.extend_from_slice(&[0; 7]); + + let pool = pool_decode(&data).unwrap(); + assert_eq!(pool.virtual_quote_reserves, 0); + } + + #[test] + 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(100, -101), None); + assert_eq!(effective_quote_reserves(u64::MAX, 1), None); } - borsh::from_slice::(&data[..POOL_SIZE]).ok() } diff --git a/src/trading/core/params/pumpswap.rs b/src/trading/core/params/pumpswap.rs index ef19ff6..83e660b 100644 --- a/src/trading/core/params/pumpswap.rs +++ b/src/trading/core/params/pumpswap.rs @@ -32,8 +32,10 @@ pub struct PumpSwapParams { pub pool_quote_token_account: Pubkey, /// Base token reserves in the pool pub pool_base_token_reserves: u64, - /// Quote token reserves in the pool + /// Raw quote-vault token balance. Pricing uses this plus [`Self::virtual_quote_reserves`]. pub pool_quote_token_reserves: u64, + /// Signed virtual quote reserves from the PumpSwap Pool account or trade event. + pub virtual_quote_reserves: i128, /// Coin creator vault ATA pub coin_creator_vault_ata: Pubkey, /// Coin creator vault authority @@ -75,6 +77,7 @@ impl PumpSwapParams { pool_quote_token_account: Pubkey, pool_base_token_reserves: u64, pool_quote_token_reserves: u64, + virtual_quote_reserves: i128, coin_creator_vault_ata: Pubkey, coin_creator_vault_authority: Pubkey, base_token_program: Pubkey, @@ -99,6 +102,7 @@ impl PumpSwapParams { pool_quote_token_account, pool_base_token_reserves, pool_quote_token_reserves, + virtual_quote_reserves, coin_creator_vault_ata, coin_creator_vault_authority, base_token_program, @@ -127,6 +131,21 @@ impl PumpSwapParams { self } + /// Quote reserves used by PumpSwap pricing and fee-tier selection. + pub fn effective_quote_reserves(&self) -> Result { + crate::instruction::utils::pumpswap_types::effective_quote_reserves( + self.pool_quote_token_reserves, + self.virtual_quote_reserves, + ) + .ok_or_else(|| { + anyhow::anyhow!( + "Invalid PumpSwap effective quote reserves: vault={} virtual={}", + self.pool_quote_token_reserves, + self.virtual_quote_reserves + ) + }) + } + pub fn with_fee_basis_points( mut self, lp_fee_basis_points: u64, @@ -161,6 +180,7 @@ impl PumpSwapParams { pool_quote_token_account: Pubkey, pool_base_token_reserves: u64, pool_quote_token_reserves: u64, + virtual_quote_reserves: i128, coin_creator_vault_ata: Pubkey, coin_creator_vault_authority: Pubkey, base_token_program: Pubkey, @@ -178,6 +198,7 @@ impl PumpSwapParams { pool_quote_token_account, pool_base_token_reserves, pool_quote_token_reserves, + virtual_quote_reserves, coin_creator_vault_ata, coin_creator_vault_authority, base_token_program, @@ -202,6 +223,7 @@ impl PumpSwapParams { pool_quote_token_account: Pubkey, pool_base_token_reserves: u64, pool_quote_token_reserves: u64, + virtual_quote_reserves: i128, coin_creator_vault_ata: Pubkey, coin_creator_vault_authority: Pubkey, base_token_program: Pubkey, @@ -223,6 +245,7 @@ impl PumpSwapParams { pool_quote_token_account, pool_base_token_reserves, pool_quote_token_reserves, + virtual_quote_reserves, coin_creator_vault_ata, coin_creator_vault_authority, base_token_program, @@ -277,6 +300,18 @@ impl PumpSwapParams { ) -> Result { let (pool_base_token_reserves, pool_quote_token_reserves) = crate::instruction::utils::pumpswap::get_token_balances(pool_data, rpc).await?; + let effective_quote_token_reserves = + crate::instruction::utils::pumpswap_types::effective_quote_reserves( + pool_quote_token_reserves, + pool_data.virtual_quote_reserves, + ) + .ok_or_else(|| { + anyhow::anyhow!( + "Invalid PumpSwap effective quote reserves: vault={} virtual={}", + pool_quote_token_reserves, + pool_data.virtual_quote_reserves + ) + })?; let base_mint_supply = fetch_mint_supply(rpc, &pool_data.base_mint).await.ok(); let fee_config = crate::instruction::utils::pumpswap::fetch_fee_config(rpc).await; let raw_fee_basis_points = crate::instruction::utils::pumpswap::compute_fee_basis_points( @@ -285,7 +320,7 @@ impl PumpSwapParams { pool_data.base_mint, base_mint_supply, pool_base_token_reserves, - pool_quote_token_reserves, + effective_quote_token_reserves, ); let creator_fee_basis_points = if pool_data.coin_creator == Pubkey::default() { 0 @@ -319,6 +354,7 @@ impl PumpSwapParams { pool_quote_token_account: pool_data.pool_quote_token_account, pool_base_token_reserves, pool_quote_token_reserves, + virtual_quote_reserves: pool_data.virtual_quote_reserves, coin_creator_vault_ata, coin_creator_vault_authority, base_token_program: if pool_data.pool_base_token_account == base_token_program_ata { 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 2/3] 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."); + } +} From dc3d2b8debb4bd6e4c7647179adc51571d840c5c Mon Sep 17 00:00:00 2001 From: 0xfnzero <0xfnzero@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:22:52 +0800 Subject: [PATCH 3/3] fix(pumpswap): harden quotes and low-latency examples --- Cargo.toml | 9 +- docs/LOW_LATENCY_BOTS.md | 2 + examples/address_lookup/Cargo.toml | 2 +- examples/bonk_copy_trading/Cargo.toml | 2 +- examples/bonk_copy_trading/src/main.rs | 52 +- examples/bonk_sniper_trading/Cargo.toml | 2 +- examples/bonk_sniper_trading/src/main.rs | 57 +-- .../meteora_damm_v2_direct_trading/Cargo.toml | 2 +- examples/middleware_system/Cargo.toml | 4 +- examples/nonce_cache/Cargo.toml | 2 +- examples/pumpfun_copy_trading/Cargo.toml | 2 +- examples/pumpfun_sniper_trading/Cargo.toml | 2 +- examples/pumpswap_direct_trading/Cargo.toml | 2 +- examples/pumpswap_trading/Cargo.toml | 2 +- examples/pumpswap_trading/README.md | 3 +- examples/pumpswap_trading/README_CN.md | 3 +- examples/pumpswap_trading/src/main.rs | 190 ++++++-- examples/raydium_amm_v4_trading/Cargo.toml | 2 +- examples/raydium_amm_v4_trading/src/main.rs | 48 +- examples/raydium_cpmm_trading/Cargo.toml | 2 +- examples/raydium_cpmm_trading/src/main.rs | 60 ++- examples/seed_trading/Cargo.toml | 2 +- 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 | 332 +++++++++++-- src/instruction/pumpswap_ix_data.rs | 9 - src/instruction/utils/pumpswap.rs | 317 +++++++++++-- src/instruction/utils/pumpswap_types.rs | 22 +- src/trading/core/params/pumpswap.rs | 60 +-- src/utils/calc/common.rs | 59 ++- src/utils/calc/pumpfun.rs | 6 +- src/utils/calc/pumpswap.rs | 235 +++++++-- 34 files changed, 1133 insertions(+), 972 deletions(-) delete mode 100644 patches/solana-keypair/Cargo.toml delete mode 100644 patches/solana-keypair/src/lib.rs delete mode 100644 patches/solana-keypair/src/seed_derivable.rs delete mode 100644 patches/solana-keypair/src/signable.rs diff --git a/Cargo.toml b/Cargo.toml index 5afc455..22188bc 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,9 @@ futures = "0.3.31" futures-util = "0.3.31" base64 = "0.22.1" bs58 = "0.5.1" +# `five8` 1.0 permits five8_core 0.1.x, whose DecodeError implements +# std::error::Error only with this feature. Solana keypair decoding requires it. +five8_core = { version = "0.1.2", features = ["std"] } rand = "0.9.0" bincode = "1.3.3" anyhow = "1.0.90" @@ -123,12 +126,6 @@ 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/docs/LOW_LATENCY_BOTS.md b/docs/LOW_LATENCY_BOTS.md index 429d0ac..a2584fd 100644 --- a/docs/LOW_LATENCY_BOTS.md +++ b/docs/LOW_LATENCY_BOTS.md @@ -20,10 +20,12 @@ Do not initialize clients, synchronously fetch a blockhash, query balances, or s | Sell an exact token amount | `SellAmount::ExactInput` | `WithMaxInput` still enforces slippage. Never use `min_out = 0` as routine error handling. +Exact-output support is protocol- and pool-direction-specific. PumpSwap exposes exact output through its on-chain `buy` instruction, but its `sell` instruction accepts exact base input plus minimum quote output; the SDK rejects `SellAmount::ExactOutput` when that direction would require `sell`. Use post-trade event reserves. Preserve PumpFun quote mint, creator/vault, token program, cashback, and mayhem fields. PumpSwap event integrations should use `from_trade_with_fee_basis_points`. Refresh delayed sells because the triggering trade and your own buy both change pool state. Durable nonce extends transaction validity, not quote validity. For `BuySlippageBelowMinBaseAmountOut`, discard the old transaction, obtain newer reserves and fee rates, enforce a quote-age limit, and rebuild only within a bounded retry policy. +After a submit timeout or ambiguous relay error, reconcile the signature and position before retrying. A retry policy may rebuild quotes automatically only when the previous transaction is known not to have been submitted. Reference examples: diff --git a/examples/address_lookup/Cargo.toml b/examples/address_lookup/Cargo.toml index 602b512..ab72f86 100644 --- a/examples/address_lookup/Cargo.toml +++ b/examples/address_lookup/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] sol-trade-sdk = { path = "../.." } -sol-parser-sdk = "0.4.14" +sol-parser-sdk = { version = "0.6.0", git = "https://github.com/0xfnzero/sol-parser-sdk", rev = "995d88991b56234a23fc1d0911fdd33caa063c67" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/bonk_copy_trading/Cargo.toml b/examples/bonk_copy_trading/Cargo.toml index f75fab6..d6a2bc8 100644 --- a/examples/bonk_copy_trading/Cargo.toml +++ b/examples/bonk_copy_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 = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/bonk_copy_trading/src/main.rs b/examples/bonk_copy_trading/src/main.rs index db9a6cd..278c1fc 100644 --- a/examples/bonk_copy_trading/src/main.rs +++ b/examples/bonk_copy_trading/src/main.rs @@ -15,13 +15,12 @@ use sol_trade_sdk::{ SolanaTrade, }; use solana_commitment_config::CommitmentConfig; -use solana_streamer_sdk::match_event; use solana_streamer_sdk::streaming::event_parser::common::filter::EventTypeFilter; use solana_streamer_sdk::streaming::event_parser::common::EventType; use solana_streamer_sdk::streaming::event_parser::protocols::bonk::parser::BONK_PROGRAM_ID; use solana_streamer_sdk::streaming::event_parser::protocols::bonk::BonkTradeEvent; -use solana_streamer_sdk::streaming::event_parser::{Protocol, UnifiedEvent}; -use solana_streamer_sdk::streaming::yellowstone_grpc::{AccountFilter, TransactionFilter}; +use solana_streamer_sdk::streaming::event_parser::{DexEvent, Protocol}; +use solana_streamer_sdk::streaming::yellowstone_grpc::TransactionFilter; use solana_streamer_sdk::streaming::YellowstoneGrpc; // Global static flag to ensure transaction is executed only once @@ -52,24 +51,19 @@ async fn main() -> Result<(), Box> { account_required, }; - // Listen to account data belonging to owner programs -> account event monitoring - let account_filter = AccountFilter { account: vec![], owner: vec![], filters: vec![] }; - // listen to specific event type - let event_type_filter = EventTypeFilter { - include: vec![ - EventType::BonkBuyExactIn, - EventType::BonkSellExactIn, - EventType::BonkBuyExactOut, - EventType::BonkSellExactOut, - ], - }; + let event_type_filter = EventTypeFilter::include_only(vec![ + EventType::BonkBuyExactIn, + EventType::BonkSellExactIn, + EventType::BonkBuyExactOut, + EventType::BonkSellExactOut, + ]); grpc.subscribe_events_immediate( protocols, None, vec![transaction_filter], - vec![account_filter], + vec![], Some(event_type_filter), None, callback, @@ -77,27 +71,25 @@ async fn main() -> Result<(), Box> { .await?; tokio::signal::ctrl_c().await?; + grpc.stop().await; Ok(()) } /// Create an event callback function that handles different types of events -fn create_event_callback() -> impl Fn(Box) { - |event: Box| { - match_event!(event, { - BonkTradeEvent => |e: BonkTradeEvent| { - // Test code, only test one transaction - if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { - let event_clone = e.clone(); - tokio::spawn(async move { - if let Err(err) = bonk_copy_trade_with_grpc(event_clone).await { - eprintln!("Error in copy trade: {:?}", err); - std::process::exit(1); - } - }); +fn create_event_callback() -> impl Fn(DexEvent) { + |event: DexEvent| { + let DexEvent::BonkTradeEvent(event) = event else { + return; + }; + if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { + tokio::spawn(async move { + if let Err(err) = bonk_copy_trade_with_grpc(event).await { + eprintln!("Error in copy trade: {:?}", err); + std::process::exit(1); } - }, - }); + }); + } } } diff --git a/examples/bonk_sniper_trading/Cargo.toml b/examples/bonk_sniper_trading/Cargo.toml index 62da9b4..f9fc75b 100644 --- a/examples/bonk_sniper_trading/Cargo.toml +++ b/examples/bonk_sniper_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 = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/bonk_sniper_trading/src/main.rs b/examples/bonk_sniper_trading/src/main.rs index 4449c19..4c44bdb 100644 --- a/examples/bonk_sniper_trading/src/main.rs +++ b/examples/bonk_sniper_trading/src/main.rs @@ -12,8 +12,8 @@ use solana_commitment_config::CommitmentConfig; use solana_streamer_sdk::streaming::event_parser::common::filter::EventTypeFilter; use solana_streamer_sdk::streaming::event_parser::common::EventType; use solana_streamer_sdk::streaming::event_parser::protocols::bonk::BonkTradeEvent; -use solana_streamer_sdk::streaming::event_parser::{Protocol, UnifiedEvent}; -use solana_streamer_sdk::{match_event, streaming::ShredStreamGrpc}; +use solana_streamer_sdk::streaming::event_parser::{DexEvent, Protocol}; +use solana_streamer_sdk::streaming::ShredStreamGrpc; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -29,16 +29,14 @@ async fn main() -> Result<(), Box> { let shred_stream = ShredStreamGrpc::new("use_your_shred_stream_url_here".to_string()).await?; let callback = create_event_callback(); let protocols = vec![Protocol::Bonk]; - let event_type_filter = EventTypeFilter { - include: vec![ - EventType::BonkBuyExactIn, - EventType::BonkBuyExactOut, - EventType::BonkSellExactIn, - EventType::BonkSellExactOut, - EventType::BonkInitialize, - EventType::BonkInitializeV2, - ], - }; + let event_type_filter = EventTypeFilter::include_only(vec![ + EventType::BonkBuyExactIn, + EventType::BonkBuyExactOut, + EventType::BonkSellExactIn, + EventType::BonkSellExactOut, + EventType::BonkInitialize, + EventType::BonkInitializeV2, + ]); println!("Starting to listen for events, press Ctrl+C to stop..."); shred_stream.shredstream_subscribe(protocols, None, Some(event_type_filter), callback).await?; tokio::signal::ctrl_c().await?; @@ -46,27 +44,22 @@ async fn main() -> Result<(), Box> { } /// Create an event callback function that handles different types of events -fn create_event_callback() -> impl Fn(Box) { - |event: Box| { - match_event!(event, { - BonkTradeEvent => |e: BonkTradeEvent| { - // Only process developer token creation events - if !e.is_dev_create_token_trade { - return; +fn create_event_callback() -> impl Fn(DexEvent) { + |event: DexEvent| { + let DexEvent::BonkTradeEvent(event) = event else { + return; + }; + if !event.is_dev_create_token_trade { + return; + } + if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { + tokio::spawn(async move { + if let Err(err) = bonk_sniper_trade_with_shreds(event).await { + eprintln!("Error in sniper trade: {:?}", err); + std::process::exit(1); } - // Ensure we only execute the trade once using atomic compare-and-swap - if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { - let event_clone = e.clone(); - // Spawn a new task to handle the trading operation - tokio::spawn(async move { - if let Err(err) = bonk_sniper_trade_with_shreds(event_clone).await { - eprintln!("Error in sniper trade: {:?}", err); - std::process::exit(1); - } - }); - } - }, - }); + }); + } } } diff --git a/examples/meteora_damm_v2_direct_trading/Cargo.toml b/examples/meteora_damm_v2_direct_trading/Cargo.toml index 5bb3583..ddb977f 100644 --- a/examples/meteora_damm_v2_direct_trading/Cargo.toml +++ b/examples/meteora_damm_v2_direct_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 = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/middleware_system/Cargo.toml b/examples/middleware_system/Cargo.toml index 42670e8..705e925 100644 --- a/examples/middleware_system/Cargo.toml +++ b/examples/middleware_system/Cargo.toml @@ -5,8 +5,8 @@ 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 = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } -anyhow = "1.0.79" \ No newline at end of file +anyhow = "1.0.79" diff --git a/examples/nonce_cache/Cargo.toml b/examples/nonce_cache/Cargo.toml index 00b4730..1cf6576 100644 --- a/examples/nonce_cache/Cargo.toml +++ b/examples/nonce_cache/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] sol-trade-sdk = { path = "../.." } -sol-parser-sdk = "0.4.14" +sol-parser-sdk = { version = "0.6.0", git = "https://github.com/0xfnzero/sol-parser-sdk", rev = "995d88991b56234a23fc1d0911fdd33caa063c67" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } spl-associated-token-account = "7.0.0" diff --git a/examples/pumpfun_copy_trading/Cargo.toml b/examples/pumpfun_copy_trading/Cargo.toml index 49bbaec..8b35754 100644 --- a/examples/pumpfun_copy_trading/Cargo.toml +++ b/examples/pumpfun_copy_trading/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] sol-trade-sdk = { path = "../.." } -sol-parser-sdk = "0.4.14" +sol-parser-sdk = { version = "0.6.0", git = "https://github.com/0xfnzero/sol-parser-sdk", rev = "995d88991b56234a23fc1d0911fdd33caa063c67" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/pumpfun_sniper_trading/Cargo.toml b/examples/pumpfun_sniper_trading/Cargo.toml index 04595b0..81f363d 100644 --- a/examples/pumpfun_sniper_trading/Cargo.toml +++ b/examples/pumpfun_sniper_trading/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] sol-trade-sdk = { path = "../.." } -sol-parser-sdk = "0.4.14" +sol-parser-sdk = { version = "0.6.0", git = "https://github.com/0xfnzero/sol-parser-sdk", rev = "995d88991b56234a23fc1d0911fdd33caa063c67" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/pumpswap_direct_trading/Cargo.toml b/examples/pumpswap_direct_trading/Cargo.toml index 43c356a..6238617 100644 --- a/examples/pumpswap_direct_trading/Cargo.toml +++ b/examples/pumpswap_direct_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 = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } 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/Cargo.toml b/examples/pumpswap_trading/Cargo.toml index 96591bb..92131e9 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 = { version = "2.0.0", git = "https://github.com/0xfnzero/solana-streamer", rev = "85c6cc901ad3f1bf8fe8010d79b92ecdd0be02b4" } +solana-streamer-sdk = { version = "2.0.0", git = "https://github.com/0xfnzero/solana-streamer", rev = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } 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 d392bcf..09127a5 100644 --- a/examples/pumpswap_trading/README.md +++ b/examples/pumpswap_trading/README.md @@ -22,7 +22,8 @@ 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. - 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. +- The first matching event asynchronously records the pre-buy balance; the next fresh event performs the trade without a balance RPC in the submission hot path. It sells only the confirmed balance increase and 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. +- If baseline warmup fails, the example waits for another event. Once transaction execution starts, an error keeps the one-shot guard locked because submission or position state may be uncertain; inspect the signatures and account state before retrying. Production bots should also add durable signature deduplication, a position state machine, SWQoS configuration, and bounded requoting. Do not solve slippage errors by setting `min_out` to zero. diff --git a/examples/pumpswap_trading/README_CN.md b/examples/pumpswap_trading/README_CN.md index 4aca4e6..e4648b0 100644 --- a/examples/pumpswap_trading/README_CN.md +++ b/examples/pumpswap_trading/README_CN.md @@ -20,7 +20,8 @@ cargo run --release --package pumpswap_trading - 买入使用 `BuyAmount::WithMaxInput`,适合优先成交的跟单/狙击场景,滑点限制最大 quote 成本。 - 买入参数使用事件中的成交后储备和 LP/protocol/creator fee bps。 - 原始 quote 储备和虚拟 quote 储备均来自同一笔交易的事件快照;热路径不再查询 Pool 账户,避免额外延迟和跨 slot 混合报价。 -- 示例记录买前余额,只卖出确认后的余额增量;卖出前重新获取池状态和 blockhash。 +- 第一个匹配事件用于异步记录买前余额,下一条新鲜事件才会交易,因此提交热路径不再查询余额;示例只卖出确认后的余额增量,并在卖出前重新获取池状态和 blockhash。 - 若业务必须精确花费 quote,应改用 `BuyAmount::ExactInput`。这会启用最小输出保护,在活跃池中更容易因状态变化而失败。 +- 基线预热失败时会等待后续事件;一旦进入交易阶段,错误会保持单次执行锁定,因为提交状态或持仓可能不确定,必须先核对签名和账户状态再重试。 生产机器人还应增加持久化签名去重、持仓状态机、SWQoS 配置和有限次数的重新报价。不要通过把 `min_out` 设为零来处理滑点错误。 diff --git a/examples/pumpswap_trading/src/main.rs b/examples/pumpswap_trading/src/main.rs index 28b2a06..6c10326 100644 --- a/examples/pumpswap_trading/src/main.rs +++ b/examples/pumpswap_trading/src/main.rs @@ -24,7 +24,7 @@ use solana_streamer_sdk::streaming::YellowstoneGrpc; use std::str::FromStr; use std::sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, RwLock, }; use std::time::{Duration, Instant}; use tokio::sync::watch; @@ -91,6 +91,18 @@ struct CachedBlockhash { fetched_at: Instant, } +#[derive(Clone, Copy)] +struct PositionBaseline { + mint: Pubkey, + token_program: Pubkey, + amount: u64, +} + +enum EventAction { + BaselineWarmed, + TradeCompleted, +} + #[derive(Clone)] struct BlockhashCache { receiver: watch::Receiver, @@ -139,6 +151,7 @@ async fn main() -> Result<(), Box> { let trade_client = Arc::new(create_solana_trade_client().await?); let blockhash_cache = BlockhashCache::start(trade_client.infrastructure.rpc.clone()).await?; + let position_baseline = Arc::new(RwLock::new(None)); let grpc = YellowstoneGrpc::new( std::env::var("GRPC_ENDPOINT") @@ -146,7 +159,8 @@ async fn main() -> Result<(), Box> { std::env::var("GRPC_AUTH_TOKEN").ok(), )?; - let callback = create_event_callback(trade_client, blockhash_cache, selection); + let callback = + create_event_callback(trade_client, blockhash_cache, position_baseline, selection); let protocols = vec![Protocol::PumpSwap]; // Filter accounts let account_include = vec![ @@ -192,6 +206,7 @@ async fn main() -> Result<(), Box> { fn create_event_callback( client: Arc, blockhash_cache: BlockhashCache, + position_baseline: Arc>>, selection: EventSelection, ) -> impl Fn(DexEvent) { move |event: DexEvent| match event { @@ -207,15 +222,38 @@ fn create_event_callback( return; } // Test code, only test one transaction - if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { + if ALREADY_EXECUTED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { let client = client.clone(); let blockhash_cache = blockhash_cache.clone(); + let position_baseline = position_baseline.clone(); + let was_preparing = + position_baseline.read().map(|baseline| baseline.is_none()).unwrap_or(true); tokio::spawn(async move { - if let Err(err) = - pumpswap_trade_with_grpc_buy_event(client, blockhash_cache, e).await + match pumpswap_trade_with_grpc_buy_event( + client, + blockhash_cache, + position_baseline, + selection, + e, + ) + .await { - eprintln!("Error in trade: {:?}", err); - std::process::exit(1); + Ok(EventAction::BaselineWarmed) => { + ALREADY_EXECUTED.store(false, Ordering::Release); + } + Ok(EventAction::TradeCompleted) => {} + Err(err) if was_preparing => { + eprintln!("baseline warmup failed; waiting for a later event: {err:?}"); + ALREADY_EXECUTED.store(false, Ordering::Release); + } + Err(err) => { + eprintln!( + "trade failed after entering execution state: {err:?}; automatic retry is disabled because submission status or position state may be uncertain" + ); + } } }); } @@ -232,15 +270,38 @@ fn create_event_callback( return; } // Test code, only test one transaction - if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { + if ALREADY_EXECUTED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { let client = client.clone(); let blockhash_cache = blockhash_cache.clone(); + let position_baseline = position_baseline.clone(); + let was_preparing = + position_baseline.read().map(|baseline| baseline.is_none()).unwrap_or(true); tokio::spawn(async move { - if let Err(err) = - pumpswap_trade_with_grpc_sell_event(client, blockhash_cache, e).await + match pumpswap_trade_with_grpc_sell_event( + client, + blockhash_cache, + position_baseline, + selection, + e, + ) + .await { - eprintln!("Error in trade: {:?}", err); - std::process::exit(1); + Ok(EventAction::BaselineWarmed) => { + ALREADY_EXECUTED.store(false, Ordering::Release); + } + Ok(EventAction::TradeCompleted) => {} + Err(err) if was_preparing => { + eprintln!("baseline warmup failed; waiting for a later event: {err:?}"); + ALREADY_EXECUTED.store(false, Ordering::Release); + } + Err(err) => { + eprintln!( + "trade failed after entering execution state: {err:?}; automatic retry is disabled because submission status or position state may be uncertain" + ); + } } }); } @@ -274,8 +335,10 @@ async fn create_solana_trade_client() -> AnyResult { async fn pumpswap_trade_with_grpc_buy_event( client: Arc, blockhash_cache: BlockhashCache, + position_baseline: Arc>>, + selection: EventSelection, trade_info: PumpSwapBuyEvent, -) -> AnyResult<()> { +) -> AnyResult { let params = PumpSwapParams::from_trade_with_fee_basis_points( trade_info.pool, trade_info.base_mint, @@ -305,16 +368,25 @@ async fn pumpswap_trade_with_grpc_buy_event( } else { trade_info.base_mint }; - pumpswap_trade_with_grpc(&client, &blockhash_cache, trade_info.metadata.recv_us, mint, params) - .await?; - Ok(()) + pumpswap_trade_with_grpc( + &client, + &blockhash_cache, + &position_baseline, + trade_info.metadata.recv_us, + selection.max_event_age_ms, + mint, + params, + ) + .await } async fn pumpswap_trade_with_grpc_sell_event( client: Arc, blockhash_cache: BlockhashCache, + position_baseline: Arc>>, + selection: EventSelection, trade_info: PumpSwapSellEvent, -) -> AnyResult<()> { +) -> AnyResult { let params = PumpSwapParams::from_trade_with_fee_basis_points( trade_info.pool, trade_info.base_mint, @@ -344,24 +416,33 @@ async fn pumpswap_trade_with_grpc_sell_event( } else { trade_info.base_mint }; - pumpswap_trade_with_grpc(&client, &blockhash_cache, trade_info.metadata.recv_us, mint, params) - .await?; - Ok(()) + pumpswap_trade_with_grpc( + &client, + &blockhash_cache, + &position_baseline, + trade_info.metadata.recv_us, + selection.max_event_age_ms, + mint, + params, + ) + .await } async fn pumpswap_trade_with_grpc( client: &SolanaTrade, blockhash_cache: &BlockhashCache, + position_baseline: &Arc>>, grpc_recv_us: i64, + max_event_age_ms: u64, mint_pubkey: Pubkey, params: PumpSwapParams, -) -> AnyResult<()> { +) -> AnyResult { println!("Testing PumpSwap trading..."); + validate_pumpswap_snapshot(¶ms)?; + if !is_event_fresh(grpc_recv_us, now_micros(), max_event_age_ms) { + anyhow::bail!("event became stale before transaction construction"); + } let slippage_basis_points = Some(500); - let recent_blockhash = blockhash_cache.latest()?; - - let gas_fee_strategy = sol_trade_sdk::common::GasFeeStrategy::new(); - gas_fee_strategy.set_global_fee_strategy(150000, 150000, 500000, 500000, 0.001, 0.001); let is_sol = params.base_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT || params.quote_mint == sol_trade_sdk::constants::WSOL_TOKEN_ACCOUNT; @@ -372,8 +453,35 @@ async fn pumpswap_trade_with_grpc( } else { anyhow::bail!("target mint {} does not belong to pool {}", mint_pubkey, params.pool); }; - let balance_before = - client.get_payer_token_balance_with_program(&mint_pubkey, &program_id).await?; + let baseline = position_baseline + .read() + .map_err(|_| anyhow::anyhow!("position baseline lock is poisoned"))? + .as_ref() + .copied(); + let balance_before = if let Some(baseline) = baseline { + if baseline.mint != mint_pubkey || baseline.token_program != program_id { + anyhow::bail!("cached position baseline belongs to a different mint or token program"); + } + baseline.amount + } else { + let amount = client.get_payer_token_balance_with_program(&mint_pubkey, &program_id).await?; + let mut baseline = position_baseline + .write() + .map_err(|_| anyhow::anyhow!("position baseline lock is poisoned"))?; + *baseline = Some(PositionBaseline { mint: mint_pubkey, token_program: program_id, amount }); + println!( + "Position baseline warmed at {} base units; waiting for the next fresh matching event", + amount + ); + return Ok(EventAction::BaselineWarmed); + }; + + let recent_blockhash = blockhash_cache.latest()?; + let gas_fee_strategy = sol_trade_sdk::common::GasFeeStrategy::new(); + gas_fee_strategy.set_global_fee_strategy(150000, 150000, 500000, 500000, 0.001, 0.001); + if !is_event_fresh(grpc_recv_us, now_micros(), max_event_age_ms) { + anyhow::bail!("event became stale while preparing the transaction"); + } // Buy tokens println!("Buying tokens from PumpSwap..."); @@ -431,8 +539,32 @@ async fn pumpswap_trade_with_grpc( anyhow::bail!("sell failed: {:?}; signatures: {:?}", err, sigs); } - // Exit program - std::process::exit(0); + println!("Round-trip example completed; further matching events remain locked out"); + Ok(EventAction::TradeCompleted) +} + +fn validate_pumpswap_snapshot(params: &PumpSwapParams) -> AnyResult<()> { + let required = [ + ("pool", params.pool), + ("base_mint", params.base_mint), + ("quote_mint", params.quote_mint), + ("pool_base_token_account", params.pool_base_token_account), + ("pool_quote_token_account", params.pool_quote_token_account), + ("coin_creator_vault_ata", params.coin_creator_vault_ata), + ("coin_creator_vault_authority", params.coin_creator_vault_authority), + ("base_token_program", params.base_token_program), + ("quote_token_program", params.quote_token_program), + ]; + for (name, value) in required { + if value == Pubkey::default() { + anyhow::bail!("event snapshot is missing {name}"); + } + } + if params.pool_base_token_reserves == 0 || params.pool_quote_token_reserves == 0 { + anyhow::bail!("event snapshot has an empty raw pool reserve"); + } + params.effective_quote_reserves()?; + Ok(()) } #[cfg(test)] diff --git a/examples/raydium_amm_v4_trading/Cargo.toml b/examples/raydium_amm_v4_trading/Cargo.toml index 202d6ef..3cda2c4 100644 --- a/examples/raydium_amm_v4_trading/Cargo.toml +++ b/examples/raydium_amm_v4_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 = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/raydium_amm_v4_trading/src/main.rs b/examples/raydium_amm_v4_trading/src/main.rs index 1d4aafa..db02ad6 100644 --- a/examples/raydium_amm_v4_trading/src/main.rs +++ b/examples/raydium_amm_v4_trading/src/main.rs @@ -12,12 +12,10 @@ use solana_commitment_config::CommitmentConfig; use solana_streamer_sdk::streaming::event_parser::common::filter::EventTypeFilter; use solana_streamer_sdk::streaming::event_parser::common::EventType; use solana_streamer_sdk::streaming::event_parser::protocols::raydium_amm_v4::parser::RAYDIUM_AMM_V4_PROGRAM_ID; -use solana_streamer_sdk::streaming::event_parser::{Protocol, UnifiedEvent}; -use solana_streamer_sdk::streaming::yellowstone_grpc::{AccountFilter, TransactionFilter}; +use solana_streamer_sdk::streaming::event_parser::protocols::raydium_amm_v4::RaydiumAmmV4SwapEvent; +use solana_streamer_sdk::streaming::event_parser::{DexEvent, Protocol}; +use solana_streamer_sdk::streaming::yellowstone_grpc::TransactionFilter; use solana_streamer_sdk::streaming::YellowstoneGrpc; -use solana_streamer_sdk::{ - match_event, streaming::event_parser::protocols::raydium_amm_v4::RaydiumAmmV4SwapEvent, -}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -51,19 +49,17 @@ async fn main() -> Result<(), Box> { account_required, }; - // Listen to account data belonging to owner programs -> account event monitoring - let account_filter = AccountFilter { account: vec![], owner: vec![], filters: vec![] }; - // listen to specific event type - let event_type_filter = EventTypeFilter { - include: vec![EventType::RaydiumAmmV4SwapBaseIn, EventType::RaydiumAmmV4SwapBaseOut], - }; + let event_type_filter = EventTypeFilter::include_only(vec![ + EventType::RaydiumAmmV4SwapBaseIn, + EventType::RaydiumAmmV4SwapBaseOut, + ]); grpc.subscribe_events_immediate( protocols, None, vec![transaction_filter], - vec![account_filter], + vec![], Some(event_type_filter), None, callback, @@ -71,27 +67,25 @@ async fn main() -> Result<(), Box> { .await?; tokio::signal::ctrl_c().await?; + grpc.stop().await; Ok(()) } /// Create an event callback function that handles different types of events -fn create_event_callback() -> impl Fn(Box) { - |event: Box| { - match_event!(event, { - RaydiumAmmV4SwapEvent => |e: RaydiumAmmV4SwapEvent| { - // Test code, only test one transaction - if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { - let event_clone = e.clone(); - tokio::spawn(async move { - if let Err(err) = raydium_amm_v4_copy_trade_with_grpc(event_clone).await { - eprintln!("Error in copy trade: {:?}", err); - std::process::exit(1); - } - }); +fn create_event_callback() -> impl Fn(DexEvent) { + |event: DexEvent| { + let DexEvent::RaydiumAmmV4SwapEvent(event) = event else { + return; + }; + if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { + tokio::spawn(async move { + if let Err(err) = raydium_amm_v4_copy_trade_with_grpc(event).await { + eprintln!("Error in copy trade: {:?}", err); + std::process::exit(1); } - }, - }); + }); + } } } diff --git a/examples/raydium_cpmm_trading/Cargo.toml b/examples/raydium_cpmm_trading/Cargo.toml index 3604b47..e3de9a8 100644 --- a/examples/raydium_cpmm_trading/Cargo.toml +++ b/examples/raydium_cpmm_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 = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/examples/raydium_cpmm_trading/src/main.rs b/examples/raydium_cpmm_trading/src/main.rs index a5b27c8..3a8f266 100644 --- a/examples/raydium_cpmm_trading/src/main.rs +++ b/examples/raydium_cpmm_trading/src/main.rs @@ -8,12 +8,10 @@ use solana_commitment_config::CommitmentConfig; use solana_streamer_sdk::streaming::event_parser::common::filter::EventTypeFilter; use solana_streamer_sdk::streaming::event_parser::common::EventType; use solana_streamer_sdk::streaming::event_parser::protocols::raydium_cpmm::parser::RAYDIUM_CPMM_PROGRAM_ID; -use solana_streamer_sdk::streaming::event_parser::{Protocol, UnifiedEvent}; -use solana_streamer_sdk::streaming::yellowstone_grpc::{AccountFilter, TransactionFilter}; +use solana_streamer_sdk::streaming::event_parser::protocols::raydium_cpmm::RaydiumCpmmSwapEvent; +use solana_streamer_sdk::streaming::event_parser::{DexEvent, Protocol}; +use solana_streamer_sdk::streaming::yellowstone_grpc::TransactionFilter; use solana_streamer_sdk::streaming::YellowstoneGrpc; -use solana_streamer_sdk::{ - match_event, streaming::event_parser::protocols::raydium_cpmm::RaydiumCpmmSwapEvent, -}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -47,19 +45,17 @@ async fn main() -> Result<(), Box> { account_required, }; - // Listen to account data belonging to owner programs -> account event monitoring - let account_filter = AccountFilter { account: vec![], owner: vec![], filters: vec![] }; - // listen to specific event type - let event_type_filter = EventTypeFilter { - include: vec![EventType::RaydiumCpmmSwapBaseInput, EventType::RaydiumCpmmSwapBaseOutput], - }; + let event_type_filter = EventTypeFilter::include_only(vec![ + EventType::RaydiumCpmmSwapBaseInput, + EventType::RaydiumCpmmSwapBaseOutput, + ]); grpc.subscribe_events_immediate( protocols, None, vec![transaction_filter], - vec![account_filter], + vec![], Some(event_type_filter), None, callback, @@ -67,32 +63,32 @@ async fn main() -> Result<(), Box> { .await?; tokio::signal::ctrl_c().await?; + grpc.stop().await; Ok(()) } /// Create an event callback function that handles different types of events -fn create_event_callback() -> impl Fn(Box) { - |event: Box| { - match_event!(event, { - RaydiumCpmmSwapEvent => |e: RaydiumCpmmSwapEvent| { - let is_wsol = e.input_token_mint == WSOL_TOKEN_ACCOUNT || e.output_token_mint == WSOL_TOKEN_ACCOUNT; - let is_usdc = e.input_token_mint == USDC_TOKEN_ACCOUNT || e.output_token_mint == USDC_TOKEN_ACCOUNT; - if !is_wsol && !is_usdc { - return; +fn create_event_callback() -> impl Fn(DexEvent) { + |event: DexEvent| { + let DexEvent::RaydiumCpmmSwapEvent(event) = event else { + return; + }; + let is_wsol = event.input_token_mint == WSOL_TOKEN_ACCOUNT + || event.output_token_mint == WSOL_TOKEN_ACCOUNT; + let is_usdc = event.input_token_mint == USDC_TOKEN_ACCOUNT + || event.output_token_mint == USDC_TOKEN_ACCOUNT; + if !is_wsol && !is_usdc { + return; + } + if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { + tokio::spawn(async move { + if let Err(err) = raydium_cpmm_copy_trade_with_grpc(event).await { + eprintln!("Error in copy trade: {:?}", err); + std::process::exit(1); } - // Test code, only test one transaction - if !ALREADY_EXECUTED.swap(true, Ordering::SeqCst) { - let event_clone = e.clone(); - tokio::spawn(async move { - if let Err(err) = raydium_cpmm_copy_trade_with_grpc(event_clone).await { - eprintln!("Error in copy trade: {:?}", err); - std::process::exit(1); - } - }); - } - }, - }); + }); + } } } diff --git a/examples/seed_trading/Cargo.toml b/examples/seed_trading/Cargo.toml index 21d33ba..3c30f0a 100644 --- a/examples/seed_trading/Cargo.toml +++ b/examples/seed_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 = "f1c6aecb3d4a4ebb2cd3c9f6a58de20b019418e2" } solana-sdk = "3.0.0" solana-commitment-config = { version = "3.0.0", features = ["serde"] } tokio = { version = "1", features = ["full"] } diff --git a/patches/solana-keypair/Cargo.toml b/patches/solana-keypair/Cargo.toml deleted file mode 100644 index 4447fb2..0000000 --- a/patches/solana-keypair/Cargo.toml +++ /dev/null @@ -1,97 +0,0 @@ -# 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 deleted file mode 100644 index 5ee5d76..0000000 --- a/patches/solana-keypair/src/lib.rs +++ /dev/null @@ -1,444 +0,0 @@ -//! 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 deleted file mode 100644 index b211e3a..0000000 --- a/patches/solana-keypair/src/seed_derivable.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! 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 deleted file mode 100644 index 1531e0b..0000000 --- a/patches/solana-keypair/src/signable.rs +++ /dev/null @@ -1,23 +0,0 @@ -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 13dddaa..bb59ae1 100755 --- a/src/instruction/pumpswap.rs +++ b/src/instruction/pumpswap.rs @@ -2,7 +2,7 @@ use crate::{ constants::trade::trade::DEFAULT_SLIPPAGE, instruction::pumpswap_ix_data::{ encode_pumpswap_buy_exact_quote_in_ix_data, encode_pumpswap_buy_ix_data, - encode_pumpswap_buy_two_args, encode_pumpswap_sell_ix_data, + encode_pumpswap_sell_ix_data, }, instruction::{ token_account_setup::{ @@ -34,6 +34,38 @@ use solana_sdk::{ /// Instruction builder for PumpSwap protocol pub struct PumpSwapInstructionBuilder; +#[inline] +fn request_mint_matches_pool(actual: Pubkey, expected: Pubkey) -> bool { + actual == expected + || (expected == crate::constants::WSOL_TOKEN_ACCOUNT + && actual == crate::constants::SOL_TOKEN_ACCOUNT) +} + +fn push_cashback_remaining_accounts( + accounts: &mut Vec, + user: &Pubkey, + quote_mint: &Pubkey, + quote_token_program: &Pubkey, + is_cashback_coin: bool, + is_buy_instruction: bool, +) -> Result<()> { + if !is_cashback_coin { + return Ok(()); + } + + let quote_ata = get_user_volume_accumulator_quote_ata(user, quote_mint, quote_token_program) + .ok_or_else(|| anyhow!("user volume accumulator quote ATA derivation failed"))?; + accounts.push(AccountMeta::new(quote_ata, false)); + + if !is_buy_instruction { + let accumulator = get_user_volume_accumulator_pda(user) + .ok_or_else(|| anyhow!("user volume accumulator PDA derivation failed"))?; + accounts.push(AccountMeta::new(accumulator, false)); + } + + Ok(()) +} + #[async_trait::async_trait] impl InstructionBuilder for PumpSwapInstructionBuilder { async fn build_buy_instructions(&self, params: &SwapParams) -> Result> { @@ -49,6 +81,9 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { if params.input_amount.unwrap_or(0) == 0 { return Err(anyhow!("Amount cannot be zero")); } + if params.fixed_output_amount == Some(0) { + return Err(anyhow!("Fixed output amount cannot be zero")); + } let pool = protocol_params.pool; let base_mint = protocol_params.base_mint; @@ -83,15 +118,28 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { // ======================================== let quote_is_wsol_or_usdc = quote_mint == crate::constants::WSOL_TOKEN_ACCOUNT || quote_mint == crate::constants::USDC_TOKEN_ACCOUNT; + if params.fixed_output_amount.is_some() && !quote_is_wsol_or_usdc { + return Err(anyhow!( + "PumpSwap exact-output buy is unsupported when the pool requires a sell instruction" + )); + } let input_stable_mint = if quote_is_wsol_or_usdc { quote_mint } else { base_mint }; let input_stable_token_program = if quote_is_wsol_or_usdc { quote_token_program } else { base_token_program }; let output_trade_mint = if quote_is_wsol_or_usdc { base_mint } else { quote_mint }; let output_trade_token_program = if quote_is_wsol_or_usdc { base_token_program } else { quote_token_program }; + if !request_mint_matches_pool(params.input_mint, input_stable_mint) + || !request_mint_matches_pool(params.output_mint, output_trade_mint) + { + return Err(anyhow!("PumpSwap buy request mints do not match the supplied pool")); + } let fee_basis_points = protocol_params.fee_basis_points; let (token_amount, sol_amount) = if let Some(output_amount) = params.fixed_output_amount { + if output_amount >= pool_base_token_reserves { + return Err(anyhow!("Exact base output must be below the pool base reserve")); + } (output_amount, params.input_amount.unwrap_or(0)) } else if quote_is_wsol_or_usdc { let result = buy_quote_input_internal_with_fees( @@ -142,7 +190,7 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { let recipient = get_protocol_fee_recipient_random(); (recipient, AccountMeta::new_readonly(recipient, false)) }; - let fee_recipient_ata = fee_recipient_ata(fee_recipient, quote_mint); + let fee_recipient_ata = fee_recipient_ata(fee_recipient, quote_mint, quote_token_program); // ======================================== // Build instructions @@ -210,12 +258,14 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { } accounts.push(accounts::FEE_CONFIG_META); accounts.push(accounts::FEE_PROGRAM_META); - // Cashback: remaining_accounts[0] = WSOL ATA of UserVolumeAccumulator (after named accounts per IDL) - if protocol_params.is_cashback_coin { - if let Some(wsol_ata) = get_user_volume_accumulator_wsol_ata(¶ms.payer.pubkey()) { - accounts.push(AccountMeta::new(wsol_ata, false)); - } - } + push_cashback_remaining_accounts( + &mut accounts, + ¶ms.payer.pubkey(), + "e_mint, + "e_token_program, + protocol_params.is_cashback_coin, + quote_is_wsol_or_usdc, + )?; // `pool-v2` only when coin_creator ≠ default (@pump-fun/pump-swap-sdk remainingAccounts); // 否则多出的一格会把 buyback pubkey 错位,触发 BuybackFeeRecipientNotAuthorized(6053)。 if protocol_params.coin_creator != Pubkey::default() { @@ -228,12 +278,16 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { let protocol_extra = get_protocol_extra_fee_recipient_random(); accounts.push(AccountMeta::new_readonly(protocol_extra, false)); accounts.push(AccountMeta::new( - crate::instruction::utils::pumpswap::fee_recipient_ata(protocol_extra, quote_mint), + crate::instruction::utils::pumpswap::fee_recipient_ata( + protocol_extra, + quote_mint, + quote_token_program, + ), false, )); // buy / buy_exact_quote_in:栈上 `[u8;25]` + `new_with_bytes`,避免每笔 `Vec` 堆分配。 - let track_volume: u8 = if protocol_params.is_cashback_coin { 1 } else { 0 }; + let track_volume = 1_u8; if quote_is_wsol_or_usdc { let ix_data = if params.fixed_output_amount.is_some() { encode_pumpswap_buy_ix_data(token_amount, sol_amount, track_volume) @@ -314,20 +368,34 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { if params.input_amount.unwrap_or_default() == 0 { return Err(anyhow!("Token amount must be greater than zero")); } + if params.fixed_output_amount == Some(0) { + return Err(anyhow!("Fixed output amount cannot be zero")); + } // ======================================== // Trade calculation and account address preparation // ======================================== let quote_is_wsol_or_usdc = quote_mint == crate::constants::WSOL_TOKEN_ACCOUNT || quote_mint == crate::constants::USDC_TOKEN_ACCOUNT; + if params.fixed_output_amount.is_some() && quote_is_wsol_or_usdc { + return Err(anyhow!( + "PumpSwap exact-output sell is unsupported when the pool requires a sell instruction" + )); + } let output_stable_mint = if quote_is_wsol_or_usdc { quote_mint } else { base_mint }; let output_stable_token_program = if quote_is_wsol_or_usdc { quote_token_program } else { base_token_program }; + let input_trade_mint = if quote_is_wsol_or_usdc { base_mint } else { quote_mint }; + if !request_mint_matches_pool(params.input_mint, input_trade_mint) + || !request_mint_matches_pool(params.output_mint, output_stable_mint) + { + return Err(anyhow!("PumpSwap sell request mints do not match the supplied pool")); + } 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")); + if output_amount >= pool_base_token_reserves { + return Err(anyhow!("Exact base output must be below the pool base reserve")); } (params.input_amount.unwrap(), output_amount) } else if quote_is_wsol_or_usdc { @@ -364,7 +432,7 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { let recipient = get_protocol_fee_recipient_random(); (recipient, AccountMeta::new_readonly(recipient, false)) }; - let fee_recipient_ata = fee_recipient_ata(fee_recipient, quote_mint); + let fee_recipient_ata = fee_recipient_ata(fee_recipient, quote_mint, quote_token_program); let user_base_token_account = crate::common::fast_fn::get_associated_token_address_with_program_id_fast_use_seed( @@ -427,20 +495,14 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { } accounts.push(accounts::FEE_CONFIG_META); accounts.push(accounts::FEE_PROGRAM_META); - // Cashback sell: 官方 remainingAccounts = [accumulator 的 quote_mint ATA, accumulator PDA, poolV2](用 quote_mint 非固定 WSOL) - if protocol_params.is_cashback_coin { - if let (Some(quote_ata), Some(accumulator)) = ( - get_user_volume_accumulator_quote_ata( - ¶ms.payer.pubkey(), - "e_mint, - "e_token_program, - ), - get_user_volume_accumulator_pda(¶ms.payer.pubkey()), - ) { - accounts.push(AccountMeta::new(quote_ata, false)); - accounts.push(AccountMeta::new(accumulator, false)); - } - } + push_cashback_remaining_accounts( + &mut accounts, + ¶ms.payer.pubkey(), + "e_mint, + "e_token_program, + protocol_params.is_cashback_coin, + !quote_is_wsol_or_usdc, + )?; if protocol_params.coin_creator != Pubkey::default() { let pool_v2 = get_pool_v2_pda(&base_mint).ok_or_else(|| { anyhow!("pool_v2 PDA derivation failed for base_mint {}", base_mint) @@ -450,18 +512,46 @@ impl InstructionBuilder for PumpSwapInstructionBuilder { let protocol_extra = get_protocol_extra_fee_recipient_random(); accounts.push(AccountMeta::new_readonly(protocol_extra, false)); accounts.push(AccountMeta::new( - crate::instruction::utils::pumpswap::fee_recipient_ata(protocol_extra, quote_mint), + crate::instruction::utils::pumpswap::fee_recipient_ata( + protocol_extra, + quote_mint, + quote_token_program, + ), false, )); // 栈数组 + `new_with_bytes`,避免 `data.to_vec()`。 - let ix_data = if quote_is_wsol_or_usdc { - encode_pumpswap_sell_ix_data(token_amount, sol_amount) + let track_volume = 1_u8; + if quote_is_wsol_or_usdc { + let ix_data = encode_pumpswap_sell_ix_data(token_amount, sol_amount); + instructions.push(Instruction::new_with_bytes( + accounts::AMM_PROGRAM, + &ix_data, + accounts, + )); + } else if params.fixed_output_amount.is_some() { + let ix_data = encode_pumpswap_buy_ix_data(sol_amount, token_amount, track_volume); + instructions.push(Instruction::new_with_bytes( + accounts::AMM_PROGRAM, + &ix_data, + accounts, + )); } else { - encode_pumpswap_buy_two_args(sol_amount, token_amount) - }; - - instructions.push(Instruction::new_with_bytes(accounts::AMM_PROGRAM, &ix_data, accounts)); + let min_base_amount_out = crate::utils::calc::common::calculate_with_slippage_sell( + sol_amount, + params.slippage_basis_points.unwrap_or(DEFAULT_SLIPPAGE), + ); + let ix_data = encode_pumpswap_buy_exact_quote_in_ix_data( + params.input_amount.unwrap_or(0), + min_base_amount_out, + track_volume, + ); + instructions.push(Instruction::new_with_bytes( + accounts::AMM_PROGRAM, + &ix_data, + accounts, + )); + } if close_wsol_ata { push_close_wsol_if_needed( @@ -558,6 +648,27 @@ mod tests { ) } + fn reverse_pumpswap_params() -> PumpSwapParams { + PumpSwapParams::new( + pk(1), + crate::constants::USDC_TOKEN_ACCOUNT, + pk(2), + pk(3), + pk(4), + 1_000_000_000, + 2_000_000_000, + 0, + pk(5), + accounts::DEFAULT_COIN_CREATOR_VAULT_AUTHORITY, + crate::constants::TOKEN_PROGRAM, + crate::constants::TOKEN_PROGRAM, + accounts::PROTOCOL_FEE_RECIPIENT, + Pubkey::default(), + false, + 0, + ) + } + fn swap_params(trade_type: TradeType, fixed_output_amount: Option) -> SwapParams { let (input_mint, output_mint) = if trade_type == TradeType::Sell { (pk(2), crate::constants::WSOL_TOKEN_ACCOUNT) @@ -616,29 +727,154 @@ mod tests { } #[tokio::test] - async fn pumpswap_sell_fixed_output_uses_min_quote_directly() { - let instructions = PumpSwapInstructionBuilder + async fn pumpswap_sell_fixed_output_rejects_unsupported_sell_instruction() { + let error = PumpSwapInstructionBuilder .build_sell_instructions(&swap_params(TradeType::Sell, Some(42))) .await - .unwrap(); - let ix = instructions.last().unwrap(); + .unwrap_err(); - assert_eq!(&ix.data[..8], crate::instruction::utils::pumpswap::SELL_DISCRIMINATOR); - assert_eq!(u64::from_le_bytes(ix.data[8..16].try_into().unwrap()), 100_000); - assert_eq!(u64::from_le_bytes(ix.data[16..24].try_into().unwrap()), 42); + assert_eq!( + error.to_string(), + "PumpSwap exact-output sell is unsupported when the pool requires a sell instruction" + ); } #[tokio::test] - async fn pumpswap_sell_fixed_output_rejects_real_vault_overflow() { + async fn pumpswap_reverse_sell_fixed_output_uses_current_buy_layout() { 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; + params.protocol_params = DexParamEnum::PumpSwap(reverse_pumpswap_params()); + params.output_mint = crate::constants::USDC_TOKEN_ACCOUNT; - let error = PumpSwapInstructionBuilder.build_sell_instructions(¶ms).await.unwrap_err(); + let instructions = + PumpSwapInstructionBuilder.build_sell_instructions(¶ms).await.unwrap(); + let ix = instructions.last().unwrap(); - assert_eq!(error.to_string(), "Minimum quote output exceeds the real quote-vault balance"); + assert_eq!(&ix.data[..8], crate::instruction::utils::pumpswap::BUY_DISCRIMINATOR); + assert_eq!(ix.data.len(), 25); + assert_eq!(u64::from_le_bytes(ix.data[8..16].try_into().unwrap()), 42); + assert_eq!(u64::from_le_bytes(ix.data[16..24].try_into().unwrap()), 100_000); + assert_eq!(ix.data[24], 1); + } + + #[tokio::test] + async fn pumpswap_reverse_sell_exact_input_never_increases_token_spend() { + let mut params = swap_params(TradeType::Sell, None); + params.protocol_params = DexParamEnum::PumpSwap(reverse_pumpswap_params()); + params.output_mint = crate::constants::USDC_TOKEN_ACCOUNT; + + let instructions = + PumpSwapInstructionBuilder.build_sell_instructions(¶ms).await.unwrap(); + let ix = instructions.last().unwrap(); + + assert_eq!( + &ix.data[..8], + crate::instruction::utils::pumpswap::BUY_EXACT_QUOTE_IN_DISCRIMINATOR + ); + assert_eq!(ix.data.len(), 25); + assert_eq!(u64::from_le_bytes(ix.data[8..16].try_into().unwrap()), 100_000); + + let quote = crate::utils::calc::pumpswap::buy_quote_input_internal_with_fees( + 100_000, + 100, + 1_000_000_000, + 2_000_000_000, + 0, + &crate::instruction::utils::pumpswap::PumpSwapFeeBasisPoints::new(25, 5, 0), + ) + .unwrap(); + let expected_min = + crate::utils::calc::common::calculate_with_slippage_sell(quote.base, 100); + assert_eq!(u64::from_le_bytes(ix.data[16..24].try_into().unwrap()), expected_min); + assert_eq!(ix.data[24], 1); + } + + #[tokio::test] + async fn pumpswap_buy_cashback_accounts_match_actual_instruction_direction() { + let mut params = swap_params(TradeType::Buy, None); + let mut protocol_params = pumpswap_params(); + protocol_params.is_cashback_coin = true; + params.protocol_params = DexParamEnum::PumpSwap(protocol_params); + + let instructions = + PumpSwapInstructionBuilder.build_buy_instructions(¶ms).await.unwrap(); + let ix = instructions.last().unwrap(); + let expected_ata = get_user_volume_accumulator_quote_ata( + ¶ms.payer.pubkey(), + &crate::constants::WSOL_TOKEN_ACCOUNT, + &crate::constants::TOKEN_PROGRAM, + ) + .unwrap(); + + assert_eq!( + &ix.data[..8], + crate::instruction::utils::pumpswap::BUY_EXACT_QUOTE_IN_DISCRIMINATOR + ); + assert_eq!(ix.accounts[23].pubkey, expected_ata); + assert_eq!(ix.data[24], 1); + } + + #[tokio::test] + async fn pumpswap_reverse_buy_uses_sell_cashback_account_layout() { + let mut params = swap_params(TradeType::Buy, None); + let mut protocol_params = reverse_pumpswap_params(); + protocol_params.is_cashback_coin = true; + params.protocol_params = DexParamEnum::PumpSwap(protocol_params); + params.input_mint = crate::constants::USDC_TOKEN_ACCOUNT; + + let instructions = + PumpSwapInstructionBuilder.build_buy_instructions(¶ms).await.unwrap(); + let ix = instructions.last().unwrap(); + let expected_ata = get_user_volume_accumulator_quote_ata( + ¶ms.payer.pubkey(), + &pk(2), + &crate::constants::TOKEN_PROGRAM, + ) + .unwrap(); + let expected_accumulator = get_user_volume_accumulator_pda(¶ms.payer.pubkey()).unwrap(); + + assert_eq!(&ix.data[..8], crate::instruction::utils::pumpswap::SELL_DISCRIMINATOR); + assert_eq!(ix.accounts[21].pubkey, expected_ata); + assert_eq!(ix.accounts[22].pubkey, expected_accumulator); + } + + #[tokio::test] + async fn pumpswap_fee_atas_use_the_quote_token_program() { + let quote_token_program = pk(99); + let mut params = swap_params(TradeType::Sell, None); + let mut protocol_params = reverse_pumpswap_params(); + protocol_params.quote_token_program = quote_token_program; + params.protocol_params = DexParamEnum::PumpSwap(protocol_params); + params.output_mint = crate::constants::USDC_TOKEN_ACCOUNT; + + let instructions = + PumpSwapInstructionBuilder.build_sell_instructions(¶ms).await.unwrap(); + let ix = instructions.last().unwrap(); + let expected_fee_ata = + crate::common::fast_fn::get_associated_token_address_with_program_id_fast( + &ix.accounts[9].pubkey, + &pk(2), + "e_token_program, + ); + let buyback_recipient_index = ix.accounts.len() - 2; + let expected_buyback_ata = + crate::common::fast_fn::get_associated_token_address_with_program_id_fast( + &ix.accounts[buyback_recipient_index].pubkey, + &pk(2), + "e_token_program, + ); + + assert_eq!(ix.accounts[10].pubkey, expected_fee_ata); + assert_eq!(ix.accounts[buyback_recipient_index + 1].pubkey, expected_buyback_ata); + } + + #[tokio::test] + async fn pumpswap_rejects_request_mints_from_another_pool() { + let mut params = swap_params(TradeType::Buy, None); + params.output_mint = pk(88); + + let error = PumpSwapInstructionBuilder.build_buy_instructions(¶ms).await.unwrap_err(); + + assert_eq!(error.to_string(), "PumpSwap buy request mints do not match the supplied pool"); } #[tokio::test] diff --git a/src/instruction/pumpswap_ix_data.rs b/src/instruction/pumpswap_ix_data.rs index 50dd86d..16d326a 100644 --- a/src/instruction/pumpswap_ix_data.rs +++ b/src/instruction/pumpswap_ix_data.rs @@ -4,15 +4,6 @@ use crate::instruction::utils::pumpswap::{ BUY_DISCRIMINATOR, BUY_EXACT_QUOTE_IN_DISCRIMINATOR, SELL_DISCRIMINATOR, }; -#[inline(always)] -pub fn encode_pumpswap_buy_two_args(base_amount_out: u64, max_quote_amount_in: u64) -> [u8; 24] { - let mut d = [0u8; 24]; - d[..8].copy_from_slice(&BUY_DISCRIMINATOR); - d[8..16].copy_from_slice(&base_amount_out.to_le_bytes()); - d[16..24].copy_from_slice(&max_quote_amount_in.to_le_bytes()); - d -} - #[inline(always)] pub fn encode_pumpswap_buy_ix_data( base_amount_out: u64, diff --git a/src/instruction/utils/pumpswap.rs b/src/instruction/utils/pumpswap.rs index 65a88a8..2a36f0e 100644 --- a/src/instruction/utils/pumpswap.rs +++ b/src/instruction/utils/pumpswap.rs @@ -2,8 +2,8 @@ use crate::{ common::{ spl_associated_token_account::get_associated_token_address_with_program_id, SolanaRpcClient, }, - constants::{TOKEN_PROGRAM, WSOL_TOKEN_ACCOUNT}, - instruction::utils::pumpswap_types::{pool_decode, Pool}, + constants::WSOL_TOKEN_ACCOUNT, + instruction::utils::pumpswap_types::{pool_decode, Pool, POOL_DISCRIMINATOR}, }; use anyhow::anyhow; use once_cell::sync::Lazy; @@ -241,6 +241,8 @@ const U8_LEN: usize = 1; const BOOL_LEN: usize = 1; const GLOBAL_CONFIG_DISCRIMINATOR_LEN: usize = 8; const FEE_CONFIG_DISCRIMINATOR_LEN: usize = 8; +const GLOBAL_CONFIG_DISCRIMINATOR: [u8; 8] = [149, 8, 156, 202, 160, 252, 176, 217]; +const FEE_CONFIG_DISCRIMINATOR: [u8; 8] = [143, 52, 146, 187, 219, 123, 76, 155]; const FEE_CONFIG_BUMP_LEN: usize = 1; const FEE_TIER_LEN: usize = 16 + U64_LEN * 3; @@ -301,6 +303,9 @@ fn read_u32(data: &[u8], offset: usize) -> Option { } fn decode_global_config(data: &[u8]) -> Option { + if data.get(..GLOBAL_CONFIG_DISCRIMINATOR_LEN)? != GLOBAL_CONFIG_DISCRIMINATOR { + return None; + } let mut offset = GLOBAL_CONFIG_DISCRIMINATOR_LEN; offset += PUBKEY_LEN; // admin let lp_fee_basis_points = read_u64(data, offset)?; @@ -364,6 +369,9 @@ fn decode_fee_tiers(data: &[u8], offset: &mut usize) -> Option Option { + if data.get(..FEE_CONFIG_DISCRIMINATOR_LEN)? != FEE_CONFIG_DISCRIMINATOR { + return None; + } let mut offset = FEE_CONFIG_DISCRIMINATOR_LEN; offset += FEE_CONFIG_BUMP_LEN; offset += PUBKEY_LEN; // admin @@ -399,6 +407,14 @@ async fn refresh_global_config_once(rpc: &SolanaRpcClient) -> Option Option Pubkey { pump_pool_authority } -pub(crate) fn coin_creator_vault_ata(coin_creator: Pubkey, quote_mint: Pubkey) -> Pubkey { +pub(crate) fn coin_creator_vault_ata( + coin_creator: Pubkey, + quote_mint: Pubkey, + quote_token_program: Pubkey, +) -> Pubkey { let creator_vault_authority = coin_creator_vault_authority(coin_creator); let associated_token_creator_vault_authority = get_associated_token_address_with_program_id( &creator_vault_authority, "e_mint, - &TOKEN_PROGRAM, + "e_token_program, ); associated_token_creator_vault_authority } -pub(crate) fn fee_recipient_ata(fee_recipient: Pubkey, quote_mint: Pubkey) -> Pubkey { +pub(crate) fn fee_recipient_ata( + fee_recipient: Pubkey, + quote_mint: Pubkey, + quote_token_program: Pubkey, +) -> Pubkey { let associated_token_fee_recipient = crate::common::fast_fn::get_associated_token_address_with_program_id_fast( &fee_recipient, "e_mint, - &TOKEN_PROGRAM, + "e_token_program, ); associated_token_fee_recipient } @@ -733,11 +765,21 @@ pub async fn fetch_pool( pool_address: &Pubkey, ) -> Result { let account = rpc.get_account(pool_address).await?; + decode_pool_account(&account).map_err(anyhow::Error::msg) +} + +fn decode_pool_account(account: &solana_sdk::account::Account) -> Result { if account.owner != accounts::AMM_PROGRAM { - return Err(anyhow!("Account is not owned by PumpSwap program")); + return Err("Account is not owned by PumpSwap program".to_string()); } - let pool = pool_decode(&account.data[8..]).ok_or_else(|| anyhow!("Failed to decode pool"))?; - Ok(pool) + let discriminator = account + .data + .get(..8) + .ok_or_else(|| "Pool account is shorter than its discriminator".to_string())?; + if discriminator != POOL_DISCRIMINATOR { + return Err("Account discriminator is not PumpSwap Pool".to_string()); + } + pool_decode(&account.data[8..]).ok_or_else(|| "Failed to decode pool".to_string()) } /// Known allocated Pool account sizes. Current accounts may be serialized to @@ -756,6 +798,9 @@ async fn get_program_accounts_known_sizes( let make_config = |data_size: u64| solana_rpc_client_api::config::RpcProgramAccountsConfig { filters: Some(vec![ solana_rpc_client_api::filter::RpcFilterType::DataSize(data_size), + solana_rpc_client_api::filter::RpcFilterType::Memcmp( + solana_client::rpc_filter::Memcmp::new_base58_encoded(0, &POOL_DISCRIMINATOR), + ), solana_rpc_client_api::filter::RpcFilterType::Memcmp( solana_client::rpc_filter::Memcmp::new_base58_encoded(memcmp_offset, mint.as_ref()), ), @@ -777,10 +822,22 @@ async fn get_program_accounts_known_sizes( 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()); + let results = [legacy_result, current_result, padded_result, extended_result]; + let mut all = Vec::new(); + let mut errors = Vec::new(); + for (size, result) in + [POOL_DATA_LEN_LEGACY, POOL_DATA_LEN_CURRENT, POOL_DATA_LEN_PADDED, POOL_DATA_LEN_EXTENDED] + .into_iter() + .zip(results) + { + match result { + Ok(accounts) => all.extend(accounts), + Err(error) => errors.push(format!("dataSize={size}: {error}")), + } + } + if !errors.is_empty() { + return Err(anyhow!("Incomplete PumpSwap pool query: {}", errors.join("; "))); + } Ok(all) } @@ -789,13 +846,7 @@ fn decode_pool_accounts( ) -> Vec<(Pubkey, Pool)> { accounts .into_iter() - .filter_map(|(addr, acc)| { - if acc.data.len() > 8 { - pool_decode(&acc.data[8..]).map(|pool| (addr, pool)) - } else { - None - } - }) + .filter_map(|(addr, acc)| decode_pool_account(&acc).ok().map(|pool| (addr, pool))) .collect() } @@ -885,17 +936,111 @@ pub async fn find_by_mint( Err(anyhow!("No pool found for mint {}. diag: {}", mint, diag_str)) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PoolRpcSnapshot { + pub base_reserve: u64, + pub quote_reserve: u64, + pub base_token_program: Pubkey, + pub quote_token_program: Pubkey, + pub base_mint_supply: u64, +} + +const TOKEN_ACCOUNT_MINT_END: usize = 32; +const TOKEN_ACCOUNT_AMOUNT_OFFSET: usize = 64; +const TOKEN_ACCOUNT_AMOUNT_END: usize = 72; +const TOKEN_ACCOUNT_STATE_OFFSET: usize = 108; +const MINT_SUPPLY_OFFSET: usize = 36; +const MINT_SUPPLY_END: usize = 44; +const MINT_INITIALIZED_OFFSET: usize = 45; + +fn supported_token_program(program: &Pubkey) -> bool { + *program == crate::constants::TOKEN_PROGRAM || *program == crate::constants::TOKEN_PROGRAM_2022 +} + +fn decode_token_account_amount( + account: &solana_sdk::account::Account, + expected_mint: &Pubkey, +) -> Result<(u64, Pubkey), anyhow::Error> { + if !supported_token_program(&account.owner) { + return Err(anyhow!("Pool vault is not owned by a supported token program")); + } + let mint = account + .data + .get(..TOKEN_ACCOUNT_MINT_END) + .ok_or_else(|| anyhow!("Pool vault data is too short"))?; + if mint != expected_mint.as_ref() { + return Err(anyhow!("Pool vault mint does not match Pool account")); + } + if account.data.get(TOKEN_ACCOUNT_STATE_OFFSET).copied() != Some(1) { + return Err(anyhow!("Pool vault is not initialized")); + } + let amount = account + .data + .get(TOKEN_ACCOUNT_AMOUNT_OFFSET..TOKEN_ACCOUNT_AMOUNT_END) + .and_then(|bytes| bytes.try_into().ok()) + .map(u64::from_le_bytes) + .ok_or_else(|| anyhow!("Pool vault amount is missing"))?; + Ok((amount, account.owner)) +} + +fn decode_mint_supply( + account: &solana_sdk::account::Account, + expected_token_program: &Pubkey, +) -> Result { + if account.owner != *expected_token_program { + return Err(anyhow!("Base mint and base vault use different token programs")); + } + if account.data.get(MINT_INITIALIZED_OFFSET).copied() != Some(1) { + return Err(anyhow!("Base mint is not initialized")); + } + account + .data + .get(MINT_SUPPLY_OFFSET..MINT_SUPPLY_END) + .and_then(|bytes| bytes.try_into().ok()) + .map(u64::from_le_bytes) + .ok_or_else(|| anyhow!("Base mint supply is missing")) +} + +pub async fn get_pool_rpc_snapshot( + pool: &Pool, + rpc: &SolanaRpcClient, +) -> Result { + let addresses = [pool.pool_base_token_account, pool.pool_quote_token_account, pool.base_mint]; + let accounts = rpc.get_multiple_accounts(&addresses).await?; + let base_vault = accounts + .first() + .and_then(Option::as_ref) + .ok_or_else(|| anyhow!("PumpSwap base vault account was not found"))?; + let quote_vault = accounts + .get(1) + .and_then(Option::as_ref) + .ok_or_else(|| anyhow!("PumpSwap quote vault account was not found"))?; + let base_mint = accounts + .get(2) + .and_then(Option::as_ref) + .ok_or_else(|| anyhow!("PumpSwap base mint account was not found"))?; + let (base_reserve, base_token_program) = + decode_token_account_amount(base_vault, &pool.base_mint)?; + let (quote_reserve, quote_token_program) = + decode_token_account_amount(quote_vault, &pool.quote_mint)?; + let base_mint_supply = decode_mint_supply(base_mint, &base_token_program)?; + + Ok(PoolRpcSnapshot { + base_reserve, + quote_reserve, + base_token_program, + quote_token_program, + base_mint_supply, + }) +} + pub async fn get_token_balances( pool: &Pool, rpc: &SolanaRpcClient, ) -> Result<(u64, u64), anyhow::Error> { - let base_balance = rpc.get_token_account_balance(&pool.pool_base_token_account).await?; - let quote_balance = rpc.get_token_account_balance(&pool.pool_quote_token_account).await?; + let snapshot = get_pool_rpc_snapshot(pool, rpc).await?; - let base_amount = base_balance.amount.parse::().map_err(|e| anyhow!(e))?; - let quote_amount = quote_balance.amount.parse::().map_err(|e| anyhow!(e))?; - - Ok((base_amount, quote_amount)) + Ok((snapshot.base_reserve, snapshot.quote_reserve)) } #[inline] @@ -909,7 +1054,40 @@ pub fn get_fee_config_pda() -> Option { #[cfg(test)] mod tests { use super::*; - use solana_sdk::pubkey::Pubkey; + use crate::instruction::utils::pumpswap_types; + use solana_sdk::{account::Account, pubkey::Pubkey}; + + fn pool_account(virtual_quote_reserves: i128) -> Account { + let mut data = Vec::with_capacity(8 + pumpswap_types::POOL_SIZE); + data.extend_from_slice(&POOL_DISCRIMINATOR); + data.push(7); + data.extend_from_slice(&42u16.to_le_bytes()); + for seed in 1..=6 { + data.extend_from_slice(Pubkey::new_from_array([seed; 32]).as_ref()); + } + data.extend_from_slice(&123_456u64.to_le_bytes()); + data.extend_from_slice(Pubkey::new_from_array([7; 32]).as_ref()); + data.push(1); + data.push(0); + data.extend_from_slice(&virtual_quote_reserves.to_le_bytes()); + Account { data, owner: accounts::AMM_PROGRAM, ..Account::default() } + } + + fn token_account(mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + let mut data = vec![0; 165]; + data[..32].copy_from_slice(mint.as_ref()); + data[TOKEN_ACCOUNT_AMOUNT_OFFSET..TOKEN_ACCOUNT_AMOUNT_END] + .copy_from_slice(&amount.to_le_bytes()); + data[TOKEN_ACCOUNT_STATE_OFFSET] = 1; + Account { data, owner, ..Account::default() } + } + + fn mint_account(owner: Pubkey, supply: u64) -> Account { + let mut data = vec![0; 82]; + data[MINT_SUPPLY_OFFSET..MINT_SUPPLY_END].copy_from_slice(&supply.to_le_bytes()); + data[MINT_INITIALIZED_OFFSET] = 1; + Account { data, owner, ..Account::default() } + } fn fee_config_fixture() -> PumpSwapFeeConfig { PumpSwapFeeConfig { @@ -936,6 +1114,25 @@ mod tests { } } + #[test] + fn config_decoders_require_official_account_discriminators() { + let global_len = 8 + 32 + 8 + 8 + 1 + 32 * 8 + 8 + 32 + 32 + 32 + 1 + 32 * 7 + 1 + 32 * 8; + let mut global_data = vec![0; global_len]; + global_data[..8].copy_from_slice(&GLOBAL_CONFIG_DISCRIMINATOR); + assert!(decode_global_config(&global_data).is_some()); + global_data[0] ^= 0xff; + assert!(decode_global_config(&global_data).is_none()); + + let mut fee_data = Vec::with_capacity(8 + 1 + 32 + 24 + 4 + 4); + fee_data.extend_from_slice(&FEE_CONFIG_DISCRIMINATOR); + fee_data.extend_from_slice(&[0; 1 + 32 + 24]); + fee_data.extend_from_slice(&0_u32.to_le_bytes()); + fee_data.extend_from_slice(&0_u32.to_le_bytes()); + assert!(decode_fee_config(&fee_data).is_some()); + fee_data[0] ^= 0xff; + assert!(decode_fee_config(&fee_data).is_none()); + } + #[test] fn pumpswap_user_volume_accumulator_pda_deterministic() { let user = Pubkey::new_unique(); @@ -1001,4 +1198,70 @@ mod tests { assert_eq!(POOL_DATA_LEN_PADDED, 300); assert_eq!(POOL_DATA_LEN_EXTENDED, 643); } + + #[test] + fn pool_account_validation_checks_owner_length_and_discriminator() { + let account = pool_account(-123_456); + assert_eq!(decode_pool_account(&account).unwrap().virtual_quote_reserves, -123_456); + + let mut wrong_owner = account.clone(); + wrong_owner.owner = Pubkey::new_unique(); + assert_eq!( + decode_pool_account(&wrong_owner).unwrap_err(), + "Account is not owned by PumpSwap program" + ); + + let mut short = account.clone(); + short.data.truncate(7); + assert_eq!( + decode_pool_account(&short).unwrap_err(), + "Pool account is shorter than its discriminator" + ); + + let mut wrong_discriminator = account; + wrong_discriminator.data[0] ^= 0xff; + assert_eq!( + decode_pool_account(&wrong_discriminator).unwrap_err(), + "Account discriminator is not PumpSwap Pool" + ); + } + + #[test] + fn pool_snapshot_decoders_validate_token_ownership_and_layout() { + let mint = Pubkey::new_unique(); + let token_program = crate::constants::TOKEN_PROGRAM_2022; + let vault = token_account(mint, token_program, 987_654_321); + assert_eq!( + decode_token_account_amount(&vault, &mint).unwrap(), + (987_654_321, token_program) + ); + assert_eq!( + decode_mint_supply(&mint_account(token_program, 42), &token_program).unwrap(), + 42 + ); + + let wrong_mint = Pubkey::new_unique(); + assert_eq!( + decode_token_account_amount(&vault, &wrong_mint).unwrap_err().to_string(), + "Pool vault mint does not match Pool account" + ); + + let unsupported = token_account(mint, Pubkey::new_unique(), 1); + assert_eq!( + decode_token_account_amount(&unsupported, &mint).unwrap_err().to_string(), + "Pool vault is not owned by a supported token program" + ); + } + + #[test] + fn coin_creator_vault_ata_uses_quote_token_program() { + let creator = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let token_program = crate::constants::TOKEN_PROGRAM_2022; + let authority = coin_creator_vault_authority(creator); + let expected = + get_associated_token_address_with_program_id(&authority, &mint, &token_program); + + assert_eq!(coin_creator_vault_ata(creator, mint, token_program), expected); + } } diff --git a/src/instruction/utils/pumpswap_types.rs b/src/instruction/utils/pumpswap_types.rs index 853c290..8779629 100644 --- a/src/instruction/utils/pumpswap_types.rs +++ b/src/instruction/utils/pumpswap_types.rs @@ -2,6 +2,8 @@ use borsh::BorshDeserialize; use serde::{Deserialize, Serialize}; use solana_sdk::pubkey::Pubkey; +pub const POOL_DISCRIMINATOR: [u8; 8] = [241, 154, 109, 4, 17, 177, 109, 188]; + #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize)] pub struct Pool { pub pool_bump: u8, @@ -26,7 +28,9 @@ pub struct Pool { /// Minimum Borsh payload length for the current Pool layout, excluding the /// 8-byte Anchor account discriminator. pub const POOL_SIZE: usize = 1 + 2 + 32 * 6 + 8 + 32 + 1 + 1 + 16; -const LEGACY_POOL_SIZE: usize = 1 + 2 + 32 * 6 + 8 + 32 + 1 + 1; +const LEGACY_POOL_FIELDS_SIZE: usize = 1 + 2 + 32 * 6 + 8 + 32 + 1 + 1; +/// Legacy Pool accounts were allocated with seven trailing padding bytes. +pub const LEGACY_POOL_SIZE: usize = LEGACY_POOL_FIELDS_SIZE + 7; #[derive(BorshDeserialize)] struct LegacyPool { @@ -69,8 +73,10 @@ pub fn pool_decode(data: &[u8]) -> Option { return borsh::from_slice::(&data[..POOL_SIZE]).ok(); } - if data.len() >= LEGACY_POOL_SIZE { - return borsh::from_slice::(&data[..LEGACY_POOL_SIZE]).ok().map(Into::into); + if data.len() == LEGACY_POOL_SIZE { + return borsh::from_slice::(&data[..LEGACY_POOL_FIELDS_SIZE]) + .ok() + .map(Into::into); } None @@ -121,13 +127,21 @@ mod tests { #[test] fn decodes_legacy_pool_with_zero_virtual_quote_reserves() { let mut data = pool_payload(0); - data.truncate(LEGACY_POOL_SIZE); + data.truncate(LEGACY_POOL_FIELDS_SIZE); data.extend_from_slice(&[0; 7]); let pool = pool_decode(&data).unwrap(); assert_eq!(pool.virtual_quote_reserves, 0); } + #[test] + fn rejects_partial_current_pool_layout() { + let current = pool_payload(987_654_321); + for len in (LEGACY_POOL_SIZE + 1)..POOL_SIZE { + assert!(pool_decode(¤t[..len]).is_none(), "accepted body length {len}"); + } + } + #[test] fn effective_reserves_support_signed_virtual_amounts_and_reject_invalid_sums() { assert_eq!(effective_quote_reserves(1_000, 250), Some(1_250)); diff --git a/src/trading/core/params/pumpswap.rs b/src/trading/core/params/pumpswap.rs index 83e660b..d20154b 100644 --- a/src/trading/core/params/pumpswap.rs +++ b/src/trading/core/params/pumpswap.rs @@ -1,13 +1,9 @@ -use crate::common::spl_associated_token_account::get_associated_token_address_with_program_id; use crate::common::SolanaRpcClient; use crate::instruction::utils::pumpswap::{ accounts::MAYHEM_FEE_RECIPIENT as MAYHEM_FEE_RECIPIENT_SWAP, PumpSwapFeeBasisPoints, }; use solana_sdk::pubkey::Pubkey; -const SPL_MINT_SUPPLY_OFFSET: usize = 36; -const SPL_MINT_SUPPLY_LEN: usize = 8; - /// PumpSwap Protocol Specific Parameters /// /// Parameters for configuring PumpSwap trading protocol, including liquidity pool information, @@ -267,17 +263,9 @@ impl PumpSwapParams { rpc: &SolanaRpcClient, mint: &Pubkey, ) -> Result { - if let Ok((pool_address, _)) = - crate::instruction::utils::pumpswap::find_by_base_mint(rpc, mint).await - { - Self::from_pool_address_by_rpc(rpc, &pool_address).await - } else if let Ok((pool_address, _)) = - crate::instruction::utils::pumpswap::find_by_quote_mint(rpc, mint).await - { - Self::from_pool_address_by_rpc(rpc, &pool_address).await - } else { - return Err(anyhow::anyhow!("No pool found for mint")); - } + let (pool_address, pool) = + crate::instruction::utils::pumpswap::find_by_mint(rpc, mint).await?; + Self::from_pool_data(rpc, &pool_address, &pool).await } pub async fn from_pool_address_by_rpc( @@ -298,8 +286,10 @@ impl PumpSwapParams { pool_address: &Pubkey, pool_data: &crate::instruction::utils::pumpswap_types::Pool, ) -> Result { - let (pool_base_token_reserves, pool_quote_token_reserves) = - crate::instruction::utils::pumpswap::get_token_balances(pool_data, rpc).await?; + let snapshot = + crate::instruction::utils::pumpswap::get_pool_rpc_snapshot(pool_data, rpc).await?; + let pool_base_token_reserves = snapshot.base_reserve; + let pool_quote_token_reserves = snapshot.quote_reserve; let effective_quote_token_reserves = crate::instruction::utils::pumpswap_types::effective_quote_reserves( pool_quote_token_reserves, @@ -312,7 +302,7 @@ impl PumpSwapParams { pool_data.virtual_quote_reserves ) })?; - let base_mint_supply = fetch_mint_supply(rpc, &pool_data.base_mint).await.ok(); + let base_mint_supply = Some(snapshot.base_mint_supply); let fee_config = crate::instruction::utils::pumpswap::fetch_fee_config(rpc).await; let raw_fee_basis_points = crate::instruction::utils::pumpswap::compute_fee_basis_points( fee_config.as_ref(), @@ -331,21 +321,11 @@ impl PumpSwapParams { let coin_creator_vault_ata = crate::instruction::utils::pumpswap::coin_creator_vault_ata( creator, pool_data.quote_mint, + snapshot.quote_token_program, ); let coin_creator_vault_authority = crate::instruction::utils::pumpswap::coin_creator_vault_authority(creator); - let base_token_program_ata = get_associated_token_address_with_program_id( - pool_address, - &pool_data.base_mint, - &crate::constants::TOKEN_PROGRAM, - ); - let quote_token_program_ata = get_associated_token_address_with_program_id( - pool_address, - &pool_data.quote_mint, - &crate::constants::TOKEN_PROGRAM, - ); - Ok(Self { pool: *pool_address, base_mint: pool_data.base_mint, @@ -357,17 +337,9 @@ impl PumpSwapParams { virtual_quote_reserves: pool_data.virtual_quote_reserves, coin_creator_vault_ata, coin_creator_vault_authority, - base_token_program: if pool_data.pool_base_token_account == base_token_program_ata { - crate::constants::TOKEN_PROGRAM - } else { - crate::constants::TOKEN_PROGRAM_2022 - }, + base_token_program: snapshot.base_token_program, is_cashback_coin: pool_data.is_cashback_coin, - quote_token_program: if pool_data.pool_quote_token_account == quote_token_program_ata { - crate::constants::TOKEN_PROGRAM - } else { - crate::constants::TOKEN_PROGRAM_2022 - }, + quote_token_program: snapshot.quote_token_program, is_mayhem_mode: pool_data.is_mayhem_mode, pool_creator: pool_data.creator, coin_creator: pool_data.coin_creator, @@ -381,13 +353,3 @@ impl PumpSwapParams { }) } } - -fn decode_mint_supply(data: &[u8]) -> Option { - let bytes = data.get(SPL_MINT_SUPPLY_OFFSET..SPL_MINT_SUPPLY_OFFSET + SPL_MINT_SUPPLY_LEN)?; - Some(u64::from_le_bytes(bytes.try_into().ok()?)) -} - -async fn fetch_mint_supply(rpc: &SolanaRpcClient, mint: &Pubkey) -> Result { - let account = rpc.get_account(mint).await?; - decode_mint_supply(&account.data).ok_or_else(|| anyhow::anyhow!("Failed to decode mint supply")) -} diff --git a/src/utils/calc/common.rs b/src/utils/calc/common.rs index db7f630..73f37bd 100644 --- a/src/utils/calc/common.rs +++ b/src/utils/calc/common.rs @@ -11,7 +11,18 @@ /// * fee_basis_points = 100 -> 1% fee #[inline(always)] pub const fn compute_fee(amount: u128, fee_basis_points: u128) -> u128 { - ceil_div(amount * fee_basis_points, 10_000) + let whole = match (amount / 10_000).checked_mul(fee_basis_points) { + Some(value) => value, + None => return u128::MAX, + }; + let remainder_product = match (amount % 10_000).checked_mul(fee_basis_points) { + Some(value) => value, + None => return u128::MAX, + }; + match whole.checked_add(ceil_div(remainder_product, 10_000)) { + Some(value) => value, + None => u128::MAX, + } } /// Ceiling division implementation @@ -25,7 +36,12 @@ pub const fn compute_fee(amount: u128, fee_basis_points: u128) -> u128 { /// Returns the ceiling result of a/b #[inline(always)] pub const fn ceil_div(a: u128, b: u128) -> u128 { - (a + b - 1) / b + let quotient = a / b; + if a % b == 0 { + quotient + } else { + quotient + 1 + } } /// Maximum slippage in basis points (99.99% = 9999 bps) @@ -55,7 +71,12 @@ pub const fn calculate_with_slippage_buy(amount: u64, basis_points: u64) -> u64 } else { basis_points }; - amount + (amount * bps / 10000) + let result = amount as u128 + (amount as u128 * bps as u128 / 10_000); + if result > u64::MAX as u128 { + u64::MAX + } else { + result as u64 + } } /// Calculate sell amount with slippage protection @@ -75,9 +96,35 @@ pub const fn calculate_with_slippage_sell(amount: u64, basis_points: u64) -> u64 if amount == 0 { return 0; } - if amount <= basis_points / 10000 { - 1 + let bps = if basis_points > MAX_SLIPPAGE_BASIS_POINTS { + MAX_SLIPPAGE_BASIS_POINTS } else { - amount - (amount * basis_points / 10000) + basis_points + }; + amount - (amount as u128 * bps as u128 / 10_000) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ceil_div_handles_u128_max_without_addition_overflow() { + assert_eq!(ceil_div(u128::MAX, u128::MAX), 1); + assert_eq!(ceil_div(u128::MAX, 2), u128::MAX / 2 + 1); + } + + #[test] + fn compute_fee_handles_large_amounts_without_multiplication_overflow() { + assert_eq!(compute_fee(u128::MAX, 1), ceil_div(u128::MAX, 10_000)); + assert_eq!(compute_fee(u128::MAX, 10_000), u128::MAX); + } + + #[test] + fn slippage_helpers_are_deterministic_at_numeric_boundaries() { + assert_eq!(calculate_with_slippage_buy(u64::MAX, 100), u64::MAX); + assert_eq!(calculate_with_slippage_sell(u64::MAX, 100), 18_262_276_632_972_456_099); + assert_eq!(calculate_with_slippage_sell(10_000, u64::MAX), 1); + assert_eq!(calculate_with_slippage_sell(1, u64::MAX), 1); } } diff --git a/src/utils/calc/pumpfun.rs b/src/utils/calc/pumpfun.rs index 7b60be3..c09ea00 100644 --- a/src/utils/calc/pumpfun.rs +++ b/src/utils/calc/pumpfun.rs @@ -89,7 +89,9 @@ pub fn get_sell_sol_amount_from_token_amount( let amount_128 = amount as u128; // Calculate SOL amount received from selling tokens using constant product formula - let numerator = amount_128.checked_mul(virtual_sol_reserves).unwrap_or(0); + let Some(numerator) = amount_128.checked_mul(virtual_sol_reserves) else { + return u64::MAX; + }; let denominator = virtual_token_reserves.checked_add(amount_128).unwrap_or(1); let sol_cost = numerator.checked_div(denominator).unwrap_or(0); @@ -101,5 +103,5 @@ pub fn get_sell_sol_amount_from_token_amount( // Calculate transaction fee let fee = compute_fee(sol_cost, total_fee_basis_points_128); - sol_cost.saturating_sub(fee) as u64 + sol_cost.saturating_sub(fee).min(u64::MAX as u128) as u64 } diff --git a/src/utils/calc/pumpswap.rs b/src/utils/calc/pumpswap.rs index 1872cb3..1964f6b 100644 --- a/src/utils/calc/pumpswap.rs +++ b/src/utils/calc/pumpswap.rs @@ -30,10 +30,22 @@ fn effective_quote_reserve( pub(crate) fn creator_side_fee_basis_points( coin_creator: &Pubkey, cashback_fee_basis_points: u64, -) -> u64 { +) -> Result { let creator_bps = if *coin_creator == Pubkey::default() { 0 } else { COIN_CREATOR_FEE_BASIS_POINTS }; - creator_bps.saturating_add(cashback_fee_basis_points) + creator_bps + .checked_add(cashback_fee_basis_points) + .ok_or_else(|| "Coin creator fee basis points overflow.".to_string()) +} + +#[inline] +fn checked_u64(value: u128, name: &str) -> Result { + u64::try_from(value).map_err(|_| format!("Calculated {name} exceeds u64.")) +} + +#[inline] +fn checked_fee(amount: u64, basis_points: u64, name: &str) -> Result { + checked_u64(compute_fee(amount as u128, basis_points as u128), name) } /// Result for buying base tokens with base amount input @@ -111,7 +123,7 @@ pub fn buy_base_input_internal( &PumpSwapFeeBasisPoints::new( LP_FEE_BASIS_POINTS, PROTOCOL_FEE_BASIS_POINTS, - creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points), + creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points)?, ), ) } @@ -140,19 +152,23 @@ pub fn buy_base_input_internal_with_fees( return Err("Pool would be depleted; denominator is zero.".to_string()); } - let quote_amount_in = ceil_div(numerator, denominator as u128) as u64; + let quote_amount_in = + checked_u64(ceil_div(numerator, denominator as u128), "raw quote amount")?; // Calculate fees - let lp_fee = - compute_fee(quote_amount_in as u128, fee_basis_points.lp_fee_basis_points as u128) as u64; + let lp_fee = checked_fee(quote_amount_in, fee_basis_points.lp_fee_basis_points, "LP fee")?; let protocol_fee = - compute_fee(quote_amount_in as u128, fee_basis_points.protocol_fee_basis_points as u128) - as u64; - let coin_creator_fee = compute_fee( - quote_amount_in as u128, - fee_basis_points.coin_creator_fee_basis_points as u128, - ) as u64; - let total_quote = quote_amount_in + lp_fee + protocol_fee + coin_creator_fee; + checked_fee(quote_amount_in, fee_basis_points.protocol_fee_basis_points, "protocol fee")?; + let coin_creator_fee = checked_fee( + quote_amount_in, + fee_basis_points.coin_creator_fee_basis_points, + "coin creator fee", + )?; + let total_quote = quote_amount_in + .checked_add(lp_fee) + .and_then(|amount| amount.checked_add(protocol_fee)) + .and_then(|amount| amount.checked_add(coin_creator_fee)) + .ok_or_else(|| "Total quote amount exceeds u64.".to_string())?; // Calculate max quote with slippage let max_quote = calculate_with_slippage_buy(total_quote, slippage_basis_points); @@ -195,7 +211,7 @@ pub fn buy_quote_input_internal( &PumpSwapFeeBasisPoints::new( LP_FEE_BASIS_POINTS, PROTOCOL_FEE_BASIS_POINTS, - creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points), + creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points)?, ), ) } @@ -216,9 +232,12 @@ pub fn buy_quote_input_internal_with_fees( // Calculate total fee basis points let total_fee_bps = fee_basis_points .lp_fee_basis_points - .saturating_add(fee_basis_points.protocol_fee_basis_points) - .saturating_add(fee_basis_points.coin_creator_fee_basis_points); - let denominator = 10_000 + total_fee_bps; + .checked_add(fee_basis_points.protocol_fee_basis_points) + .and_then(|fees| fees.checked_add(fee_basis_points.coin_creator_fee_basis_points)) + .ok_or_else(|| "Fee basis points overflow.".to_string())?; + let denominator = 10_000_u64 + .checked_add(total_fee_bps) + .ok_or_else(|| "Fee denominator overflow.".to_string())?; // Calculate effective quote amount after fees let mut effective_quote = (quote as u128 * 10_000) / denominator as u128; @@ -227,11 +246,19 @@ pub fn buy_quote_input_internal_with_fees( compute_fee(effective_quote, fee_basis_points.protocol_fee_basis_points as u128); let coin_creator_fee = compute_fee(effective_quote, fee_basis_points.coin_creator_fee_basis_points as u128); - let total_with_fees = effective_quote + lp_fee + protocol_fee + coin_creator_fee; + let total_with_fees = effective_quote + .checked_add(lp_fee) + .and_then(|amount| amount.checked_add(protocol_fee)) + .and_then(|amount| amount.checked_add(coin_creator_fee)) + .ok_or_else(|| "Total quote amount exceeds u128.".to_string())?; if total_with_fees > quote as u128 { - effective_quote = effective_quote.saturating_sub(total_with_fees - quote as u128); + effective_quote = effective_quote + .checked_sub(total_with_fees - quote as u128) + .ok_or_else(|| "Quote input is too small to cover fees.".to_string())?; } - let input_amount = effective_quote.saturating_sub(1); + let input_amount = effective_quote + .checked_sub(1) + .ok_or_else(|| "Quote input is too small after fees.".to_string())?; // Calculate base amount out using constant product formula let numerator = (base_reserve as u128) * input_amount; @@ -241,14 +268,14 @@ pub fn buy_quote_input_internal_with_fees( return Err("Pool would be depleted; denominator is zero.".to_string()); } - let base_amount_out = (numerator / denominator_effective) as u64; + let base_amount_out = checked_u64(numerator / denominator_effective, "base amount")?; // Calculate max quote with slippage let max_quote = calculate_with_slippage_buy(quote, slippage_basis_points); Ok(BuyQuoteInputResult { base: base_amount_out, - internal_quote_without_fees: effective_quote as u64, + internal_quote_without_fees: checked_u64(effective_quote, "effective quote amount")?, max_quote, }) } @@ -284,7 +311,7 @@ pub fn sell_base_input_internal( &PumpSwapFeeBasisPoints::new( LP_FEE_BASIS_POINTS, PROTOCOL_FEE_BASIS_POINTS, - creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points), + creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points)?, ), ) } @@ -303,22 +330,27 @@ pub fn sell_base_input_internal_with_fees( let effective_quote_reserve = effective_quote_reserve(quote_reserve, virtual_quote_reserves)?; // Calculate quote amount out using constant product formula - let quote_amount_out = ((effective_quote_reserve as u128) * (base as u128) - / ((base_reserve as u128) + (base as u128))) as u64; + let quote_amount_out = checked_u64( + (effective_quote_reserve as u128) * (base as u128) + / ((base_reserve as u128) + (base as u128)), + "raw quote amount", + )?; // Calculate fees - let lp_fee = - compute_fee(quote_amount_out as u128, fee_basis_points.lp_fee_basis_points as u128) as u64; + let lp_fee = checked_fee(quote_amount_out, fee_basis_points.lp_fee_basis_points, "LP fee")?; let protocol_fee = - compute_fee(quote_amount_out as u128, fee_basis_points.protocol_fee_basis_points as u128) - as u64; - let coin_creator_fee = compute_fee( - quote_amount_out as u128, - fee_basis_points.coin_creator_fee_basis_points as u128, - ) as u64; + checked_fee(quote_amount_out, fee_basis_points.protocol_fee_basis_points, "protocol fee")?; + let coin_creator_fee = checked_fee( + quote_amount_out, + fee_basis_points.coin_creator_fee_basis_points, + "coin creator fee", + )?; // Calculate final quote after fees - let total_fees = lp_fee + protocol_fee + coin_creator_fee; + let total_fees = lp_fee + .checked_add(protocol_fee) + .and_then(|fees| fees.checked_add(coin_creator_fee)) + .ok_or_else(|| "Total fees exceed u64.".to_string())?; if total_fees > quote_amount_out { return Err("Fees exceed total output; final quote is negative.".to_string()); } @@ -395,7 +427,7 @@ pub fn sell_quote_input_internal( &PumpSwapFeeBasisPoints::new( LP_FEE_BASIS_POINTS, PROTOCOL_FEE_BASIS_POINTS, - creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points), + creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points)?, ), ) } @@ -424,9 +456,11 @@ pub fn sell_quote_input_internal_with_fees( 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 { + let lp_fee = checked_fee(raw_quote, fee_basis_points.lp_fee_basis_points, "LP fee")?; + let quote_vault_outflow = raw_quote + .checked_sub(lp_fee) + .ok_or_else(|| "LP fee exceeds raw quote output.".to_string())?; + if quote_vault_outflow > quote_reserve { return Err("Insufficient real quote reserves to cover the sell output.".to_string()); } @@ -435,10 +469,13 @@ pub fn sell_quote_input_internal_with_fees( 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), - (effective_quote_reserve - raw_quote) as u128, - ) as u64; + let base_amount_in = checked_u64( + ceil_div( + (base_reserve as u128) * (raw_quote as u128), + (effective_quote_reserve - raw_quote) as u128, + ), + "base amount", + )?; // Calculate min quote with slippage let min_quote = calculate_with_slippage_sell(quote, slippage_basis_points); @@ -531,4 +568,118 @@ mod tests { assert_eq!(error, "Invalid effective quote reserves: raw=1000000, virtual=-1000000."); } + + #[test] + fn quote_modes_match_official_integer_formulas() { + let fees = PumpSwapFeeBasisPoints::new(20, 5, 30); + let base_reserve = 800_000_000_000_000; + let quote_reserve = 100_000_000_000; + let virtual_quote_reserves = 5_000_000_000; + let slippage_basis_points = 125; + + let buy_base = buy_base_input_internal_with_fees( + 123_456_789_000, + slippage_basis_points, + base_reserve, + quote_reserve, + virtual_quote_reserves, + &fees, + ) + .unwrap(); + assert_eq!(buy_base.internal_quote_amount, 16_206_205); + assert_eq!(buy_base.ui_quote, 16_295_341); + assert_eq!(buy_base.max_quote, 16_499_032); + + let buy_quote = buy_quote_input_internal_with_fees( + 1_500_000_000, + slippage_basis_points, + base_reserve, + quote_reserve, + virtual_quote_reserves, + &fees, + ) + .unwrap(); + assert_eq!(buy_quote.internal_quote_without_fees, 1_491_795_125); + assert_eq!(buy_quote.base, 11_206_836_149_304); + assert_eq!(buy_quote.max_quote, 1_518_750_000); + + let sell_base = sell_base_input_internal_with_fees( + 123_456_789_000, + slippage_basis_points, + base_reserve, + quote_reserve, + virtual_quote_reserves, + &fees, + ) + .unwrap(); + assert_eq!(sell_base.internal_quote_amount_out, 16_201_203); + assert_eq!(sell_base.ui_quote, 16_112_095); + assert_eq!(sell_base.min_quote, 15_910_694); + + let sell_quote = sell_quote_input_internal_with_fees( + 500_000_000, + slippage_basis_points, + base_reserve, + quote_reserve, + virtual_quote_reserves, + &fees, + ) + .unwrap(); + assert_eq!(sell_quote.internal_raw_quote, 502_765_209); + assert_eq!(sell_quote.base, 3_849_022_110_532); + assert_eq!(sell_quote.min_quote, 493_750_000); + } + + #[test] + fn oversized_quote_results_return_errors_instead_of_truncating() { + let no_fees = PumpSwapFeeBasisPoints::new(0, 0, 0); + + let buy_error = + buy_base_input_internal_with_fees(u64::MAX - 1, 0, u64::MAX, u64::MAX, 0, &no_fees) + .unwrap_err(); + assert_eq!(buy_error, "Calculated raw quote amount exceeds u64."); + + let sell_error = + sell_quote_input_internal_with_fees(u64::MAX - 1, 0, u64::MAX, u64::MAX, 0, &no_fees) + .unwrap_err(); + assert_eq!(sell_error, "Calculated base amount exceeds u64."); + } + + #[test] + fn invalid_fee_boundaries_return_errors() { + let overflowing_fees = PumpSwapFeeBasisPoints::new(u64::MAX, 1, 0); + let error = buy_quote_input_internal_with_fees( + 10_000, + 0, + 1_000_000, + 1_000_000, + 0, + &overflowing_fees, + ) + .unwrap_err(); + assert_eq!(error, "Fee basis points overflow."); + + let oversized_fee = PumpSwapFeeBasisPoints::new(u64::MAX, 0, 0); + let error = sell_base_input_internal_with_fees( + 1_000_000, + 0, + 1_000_000, + 1_000_000, + 0, + &oversized_fee, + ) + .unwrap_err(); + assert_eq!(error, "Calculated LP fee exceeds u64."); + + let error = creator_side_fee_basis_points(&Pubkey::new_unique(), u64::MAX).unwrap_err(); + assert_eq!(error, "Coin creator fee basis points overflow."); + } + + #[test] + fn buy_quote_rejects_amount_too_small_after_fees() { + let error = + buy_quote_input_internal_with_fees(1, 0, 1_000_000, 1_000_000, 0, &fees()).unwrap_err(); + + assert_eq!(error, "Quote input is too small after fees."); + } }