Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e92f8f333 | |||
| cf3bef0dd5 | |||
| c76c8d6ac2 | |||
| dc3d2b8deb | |||
| fb1ff176d0 | |||
| dd41dd4f87 | |||
| 8bef655abb | |||
| 100156424c |
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sol-trade-sdk"
|
||||
version = "4.0.22"
|
||||
version = "5.0.0"
|
||||
edition = "2021"
|
||||
authors = [
|
||||
"William <byteblock6@gmail.com>",
|
||||
@@ -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"
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
- [📊 Usage Examples Summary Table](#-usage-examples-summary-table)
|
||||
- [⚙️ SWQoS Service Configuration](#️-swqos-service-configuration)
|
||||
- [Astralane (Binary / Plain / QUIC)](#astralane-binary--plain--quic)
|
||||
- [Glaive (Binary HTTP / QUIC)](#glaive-binary-http--quic)
|
||||
- [🔧 Middleware System](#-middleware-system)
|
||||
- [🔍 Address Lookup Tables](#-address-lookup-tables)
|
||||
- [🔍 Nonce Cache](#-nonce-cache)
|
||||
@@ -86,15 +87,15 @@ This SDK is available in multiple languages:
|
||||
| Area | Coverage |
|
||||
|------|----------|
|
||||
| DEX protocols | PumpFun, PumpSwap, Bonk, Meteora DAMM v2, Raydium AMM v4, Raydium CPMM |
|
||||
| Submit lanes | Default Solana RPC plus Jito, Nextblock, ZeroSlot, Temporal, Bloxroute, FlashBlock, BlockRazor, Node1, Astralane, SpeedLanding, and other SWQoS providers |
|
||||
| Submit lanes | Default Solana RPC plus Jito, Nextblock, ZeroSlot, Temporal, Bloxroute, FlashBlock, BlockRazor, Node1, Astralane, Glaive, SpeedLanding, and other SWQoS providers |
|
||||
| Trading workflows | Buy/sell, exact input/output, copy trading, sniper trading, address lookup tables, durable nonce, middleware, shared infrastructure |
|
||||
| Hot-path design | Caller supplies recent blockhash or durable nonce; trade execution avoids RPC reads for blockhash, account, or balance data |
|
||||
|
||||
## 🔖 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
|
||||
|
||||
@@ -104,7 +105,7 @@ This release refreshes PumpFun native-SOL quote handling so SOL/WSOL sentinels p
|
||||
4. **Raydium CPMM Trading**: Support for Raydium CPMM (Concentrated Pool Market Maker) trading operations
|
||||
5. **Raydium AMM V4 Trading**: Support for Raydium AMM V4 (Automated Market Maker) trading operations
|
||||
6. **Meteora DAMM V2 Trading**: Support for Meteora DAMM V2 (Dynamic AMM) trading operations
|
||||
7. **Multiple MEV Protection**: Support for Jito, Nextblock, ZeroSlot, Temporal, Bloxroute, FlashBlock, BlockRazor, Node1, Astralane, LunarLander and other services
|
||||
7. **Multiple MEV Protection**: Support for Jito, Nextblock, ZeroSlot, Temporal, Bloxroute, FlashBlock, BlockRazor, Node1, Astralane, Glaive, LunarLander and other services
|
||||
8. **Concurrent Trading**: Submit through every configured SWQoS provider plus the default RPC lane; the first accepted result can return early while slower routes continue submitting
|
||||
9. **Unified Trading Interface**: Use unified trading protocol enums for trading operations
|
||||
10. **Middleware System**: Support for custom instruction middleware to modify, add, or remove instructions before transaction execution
|
||||
@@ -126,14 +127,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
|
||||
@@ -169,6 +170,13 @@ let swqos_configs: Vec<SwqosConfig> = vec![
|
||||
None,
|
||||
Some(SwqosTransport::Http),
|
||||
),
|
||||
// Glaive: None = QUIC (default, UDP/4000); Some(Http) = binary HTTP
|
||||
SwqosConfig::Glaive(
|
||||
"your_glaive_uuid_v4_api_key".to_string(),
|
||||
SwqosRegion::Frankfurt,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
];
|
||||
// Create TradeConfig instance
|
||||
let trade_config = TradeConfig::builder(rpc_url, swqos_configs, commitment)
|
||||
@@ -177,7 +185,7 @@ let trade_config = TradeConfig::builder(rpc_url, swqos_configs, commitment)
|
||||
// .log_enabled(true) // default: true - SDK timing / SWQOS logs
|
||||
// .check_min_tip(false) // default: false - filter SWQOS below min tip
|
||||
// .swqos_cores_from_end(false) // default: false - bind SWQOS to last N CPU cores
|
||||
// .mev_protection(false) // default: false - MEV (Astralane QUIC :9000 or HTTP mev-protect / BlockRazor)
|
||||
// .mev_protection(false) // default: false - MEV protection for Astralane / BlockRazor / Glaive
|
||||
.build();
|
||||
|
||||
// Create TradingClient
|
||||
@@ -348,6 +356,7 @@ let temporal_config = SwqosConfig::Temporal(
|
||||
- If a custom URL is provided (`Some(url)`), it will be used instead of the regional endpoint
|
||||
- If no custom URL is provided (`None`), the system will use the default endpoint for the specified `SwqosRegion`
|
||||
- This allows for maximum flexibility while maintaining backward compatibility
|
||||
- For Glaive, a custom QUIC URL is `host:4000`; a custom HTTP URL is an absolute `http://` or `https://` base URL. The SDK appends `/binary` and authentication parameters for HTTP.
|
||||
|
||||
When using multiple MEV services, you need to use `Durable Nonce`. Fetch the latest nonce value and attach it to the high-level buy/sell params:
|
||||
|
||||
@@ -396,6 +405,42 @@ let swqos_configs: Vec<SwqosConfig> = vec for credentials, rate limits, and protocol details.
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Middleware System
|
||||
@@ -506,6 +551,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)
|
||||
@@ -515,6 +568,7 @@ You can apply for a key through the official website: [Community Website](https:
|
||||
- **FlashBlock**: High-speed transaction execution with API key authentication
|
||||
- **BlockRazor**: High-speed transaction execution with API key authentication
|
||||
- **Astralane**: Blockchain network acceleration (Binary/Plain HTTP and QUIC)
|
||||
- **Glaive**: Persistent QUIC and binary HTTP transaction delivery (minimum tip: 0.0001 SOL)
|
||||
- **SpeedLanding**: High-speed transaction execution with API key authentication
|
||||
- **Node1**: High-speed transaction execution with API key authentication
|
||||
- **LunarLander**: HelloMoon transaction landing service (minimum tip: 0.001 SOL)
|
||||
|
||||
+61
-7
@@ -55,6 +55,7 @@
|
||||
- [📊 使用示例汇总表格](#-使用示例汇总表格)
|
||||
- [⚙️ SWQoS 服务配置说明](#️-swqos-服务配置说明)
|
||||
- [Astralane(Binary / Plain / QUIC)](#astralanebinary--plain--quic)
|
||||
- [Glaive(Binary HTTP / QUIC)](#glaivebinary-http--quic)
|
||||
- [🔧 中间件系统说明](#-中间件系统说明)
|
||||
- [🔍 地址查找表](#-地址查找表)
|
||||
- [🔍 Nonce 缓存](#-nonce-缓存)
|
||||
@@ -86,15 +87,15 @@
|
||||
| 方向 | 覆盖范围 |
|
||||
|------|----------|
|
||||
| DEX 协议 | PumpFun、PumpSwap、Bonk、Meteora DAMM v2、Raydium AMM v4、Raydium CPMM |
|
||||
| 提交通道 | 默认 Solana RPC,以及 Jito、Nextblock、ZeroSlot、Temporal、Bloxroute、FlashBlock、BlockRazor、Node1、Astralane、SpeedLanding 等 SWQoS 服务 |
|
||||
| 提交通道 | 默认 Solana RPC,以及 Jito、Nextblock、ZeroSlot、Temporal、Bloxroute、FlashBlock、BlockRazor、Node1、Astralane、Glaive、SpeedLanding 等 SWQoS 服务 |
|
||||
| 交易流程 | 买入/卖出、精确输入/输出、跟单交易、狙击交易、地址查找表、durable nonce、中间件、共享基础设施 |
|
||||
| 热路径设计 | 调用方传入 recent blockhash 或 durable nonce;交易执行阶段不再查询 RPC 获取 blockhash、账户或余额 |
|
||||
|
||||
## 🔖 当前版本
|
||||
|
||||
**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`。
|
||||
|
||||
## ✨ 项目特性
|
||||
|
||||
@@ -104,7 +105,7 @@
|
||||
4. **Raydium CPMM 交易**: 支持 Raydium CPMM (Concentrated Pool Market Maker) 的交易操作
|
||||
5. **Raydium AMM V4 交易**: 支持 Raydium AMM V4 (Automated Market Maker) 的交易操作
|
||||
6. **Meteora DAMM V2 交易**: 支持 Meteora DAMM V2 (Dynamic AMM) 的交易操作
|
||||
7. **多种 MEV 保护**: 支持 Jito、Nextblock、ZeroSlot、Temporal、Bloxroute、FlashBlock、BlockRazor、Node1、Astralane、SpeedLanding、LunarLander 等服务
|
||||
7. **多种 MEV 保护**: 支持 Jito、Nextblock、ZeroSlot、Temporal、Bloxroute、FlashBlock、BlockRazor、Node1、Astralane、Glaive、SpeedLanding、LunarLander 等服务
|
||||
8. **并发交易**: 所有已配置的 SWQoS 通道和默认 RPC 通道都会发出提交;首个成功只影响返回,较慢通道会继续提交
|
||||
9. **统一交易接口**: 使用统一的交易协议枚举进行交易操作
|
||||
10. **中间件系统**: 支持自定义指令中间件,可在交易执行前对指令进行修改、添加或移除
|
||||
@@ -126,14 +127,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"
|
||||
```
|
||||
|
||||
## 🛠️ 使用示例
|
||||
@@ -169,6 +170,13 @@ let swqos_configs: Vec<SwqosConfig> = vec![
|
||||
None,
|
||||
Some(SwqosTransport::Http),
|
||||
),
|
||||
// Glaive:None 为 QUIC(默认,UDP/4000);Some(Http) 为 binary HTTP
|
||||
SwqosConfig::Glaive(
|
||||
"your_glaive_uuid_v4_api_key".to_string(),
|
||||
SwqosRegion::Frankfurt,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
];
|
||||
// 创建 TradeConfig 实例
|
||||
let trade_config = TradeConfig::builder(rpc_url, swqos_configs, commitment)
|
||||
@@ -177,7 +185,7 @@ let trade_config = TradeConfig::builder(rpc_url, swqos_configs, commitment)
|
||||
// .log_enabled(true) // 默认: true - SDK 计时 / SWQOS 日志
|
||||
// .check_min_tip(false) // 默认: false - 过滤低于最低小费的 SWQOS
|
||||
// .swqos_cores_from_end(false) // 默认: false - 将 SWQOS 绑定到末尾 N 个 CPU 核心
|
||||
// .mev_protection(false) // 默认: false - MEV(Astralane QUIC :9000 或 HTTP mev-protect / BlockRazor)
|
||||
// .mev_protection(false) // 默认: false - Astralane / BlockRazor / Glaive 的 MEV 保护
|
||||
.build();
|
||||
|
||||
// 创建 TradingClient
|
||||
@@ -347,6 +355,7 @@ let temporal_config = SwqosConfig::Temporal(
|
||||
- 如果提供了自定义 URL(`Some(url)`),将使用自定义 URL 而不是区域端点
|
||||
- 如果没有提供自定义 URL(`None`),系统将使用指定 `SwqosRegion` 的默认端点
|
||||
- 这提供了最大的灵活性,同时保持向后兼容性
|
||||
- Glaive 自定义 QUIC 地址格式为 `host:4000`;自定义 HTTP 地址必须是完整的 `http://` 或 `https://` 基础 URL,SDK 会自动追加 `/binary` 和鉴权参数。
|
||||
|
||||
当使用多个 MEV 服务时,需要使用 `Durable Nonce`。先获取最新 nonce,再挂到新的 buy/sell 参数上:
|
||||
|
||||
@@ -395,6 +404,42 @@ let swqos_configs: Vec<SwqosConfig> = vec。
|
||||
|
||||
---
|
||||
|
||||
### 🔧 中间件系统说明
|
||||
@@ -503,6 +548,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)
|
||||
@@ -512,6 +565,7 @@ legacy SOL 事件里如果 `quote_mint` 是默认值或 Solscan SOL,并且 quo
|
||||
- **FlashBlock**: 高速交易执行,支持 API 密钥认证
|
||||
- **BlockRazor**: 高速交易执行,支持 API 密钥认证
|
||||
- **Astralane**: 区块链网络加速(Binary/Plain HTTP 与 QUIC)
|
||||
- **Glaive**: 持久 QUIC 与 binary HTTP 交易投递(最低 tip:0.0001 SOL)
|
||||
- **SpeedLanding**: 高速交易执行,支持 API 密钥认证
|
||||
- **Node1**: 高速交易执行,支持 API 密钥认证
|
||||
- **LunarLander**: HelloMoon 交易着陆服务(最低小费:0.001 SOL)
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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<dyn UnifiedEvent>) {
|
||||
|event: Box<dyn UnifiedEvent>| {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
/// Create an event callback function that handles different types of events
|
||||
fn create_event_callback() -> impl Fn(Box<dyn UnifiedEvent>) {
|
||||
|event: Box<dyn UnifiedEvent>| {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"
|
||||
anyhow = "1.0.79"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -21,7 +21,9 @@ 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 example records the pre-buy balance and sells only the confirmed balance increase. It refreshes pool state and blockhash before selling.
|
||||
- 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 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.
|
||||
|
||||
@@ -19,7 +19,9 @@ cargo run --release --package pumpswap_trading
|
||||
|
||||
- 买入使用 `BuyAmount::WithMaxInput`,适合优先成交的跟单/狙击场景,滑点限制最大 quote 成本。
|
||||
- 买入参数使用事件中的成交后储备和 LP/protocol/creator fee bps。
|
||||
- 示例记录买前余额,只卖出确认后的余额增量;卖出前重新获取池状态和 blockhash。
|
||||
- 原始 quote 储备和虚拟 quote 储备均来自同一笔交易的事件快照;热路径不再查询 Pool 账户,避免额外延迟和跨 slot 混合报价。
|
||||
- 第一个匹配事件用于异步记录买前余额,下一条新鲜事件才会交易,因此提交热路径不再查询余额;示例只卖出确认后的余额增量,并在卖出前重新获取池状态和 blockhash。
|
||||
- 若业务必须精确花费 quote,应改用 `BuyAmount::ExactInput`。这会启用最小输出保护,在活跃池中更容易因状态变化而失败。
|
||||
- 基线预热失败时会等待后续事件;一旦进入交易阶段,错误会保持单次执行锁定,因为提交状态或持仓可能不确定,必须先核对签名和账户状态再重试。
|
||||
|
||||
生产机器人还应增加持久化签名去重、持仓状态机、SWQoS 配置和有限次数的重新报价。不要通过把 `min_out` 设为零来处理滑点错误。
|
||||
|
||||
@@ -11,22 +11,20 @@ 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},
|
||||
Arc,
|
||||
Arc, RwLock,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::watch;
|
||||
@@ -93,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<CachedBlockhash>,
|
||||
@@ -141,6 +151,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
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")
|
||||
@@ -148,7 +159,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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![
|
||||
@@ -169,8 +181,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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,
|
||||
@@ -192,63 +206,107 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn create_event_callback(
|
||||
client: Arc<SolanaTrade>,
|
||||
blockhash_cache: BlockhashCache,
|
||||
position_baseline: Arc<RwLock<Option<PositionBaseline>>>,
|
||||
selection: EventSelection,
|
||||
) -> impl Fn(Box<dyn UnifiedEvent>) {
|
||||
move |event: Box<dyn UnifiedEvent>| {
|
||||
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
|
||||
.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 {
|
||||
match pumpswap_trade_with_grpc_buy_event(
|
||||
client,
|
||||
blockhash_cache,
|
||||
position_baseline,
|
||||
selection,
|
||||
e,
|
||||
)
|
||||
.await
|
||||
{
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
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
|
||||
.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 {
|
||||
match pumpswap_trade_with_grpc_sell_event(
|
||||
client,
|
||||
blockhash_cache,
|
||||
position_baseline,
|
||||
selection,
|
||||
e,
|
||||
)
|
||||
.await
|
||||
{
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,8 +335,10 @@ async fn create_solana_trade_client() -> AnyResult<SolanaTrade> {
|
||||
async fn pumpswap_trade_with_grpc_buy_event(
|
||||
client: Arc<SolanaTrade>,
|
||||
blockhash_cache: BlockhashCache,
|
||||
position_baseline: Arc<RwLock<Option<PositionBaseline>>>,
|
||||
selection: EventSelection,
|
||||
trade_info: PumpSwapBuyEvent,
|
||||
) -> AnyResult<()> {
|
||||
) -> AnyResult<EventAction> {
|
||||
let params = PumpSwapParams::from_trade_with_fee_basis_points(
|
||||
trade_info.pool,
|
||||
trade_info.base_mint,
|
||||
@@ -287,6 +347,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,
|
||||
trade_info.virtual_quote_reserves,
|
||||
trade_info.coin_creator_vault_ata,
|
||||
trade_info.coin_creator_vault_authority,
|
||||
trade_info.base_token_program,
|
||||
@@ -294,8 +355,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,
|
||||
@@ -307,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<SolanaTrade>,
|
||||
blockhash_cache: BlockhashCache,
|
||||
position_baseline: Arc<RwLock<Option<PositionBaseline>>>,
|
||||
selection: EventSelection,
|
||||
trade_info: PumpSwapSellEvent,
|
||||
) -> AnyResult<()> {
|
||||
) -> AnyResult<EventAction> {
|
||||
let params = PumpSwapParams::from_trade_with_fee_basis_points(
|
||||
trade_info.pool,
|
||||
trade_info.base_mint,
|
||||
@@ -325,6 +395,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,
|
||||
trade_info.virtual_quote_reserves,
|
||||
trade_info.coin_creator_vault_ata,
|
||||
trade_info.coin_creator_vault_authority,
|
||||
trade_info.base_token_program,
|
||||
@@ -332,8 +403,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,
|
||||
@@ -345,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<RwLock<Option<PositionBaseline>>>,
|
||||
grpc_recv_us: i64,
|
||||
max_event_age_ms: u64,
|
||||
mint_pubkey: Pubkey,
|
||||
params: PumpSwapParams,
|
||||
) -> AnyResult<()> {
|
||||
) -> AnyResult<EventAction> {
|
||||
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;
|
||||
@@ -373,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...");
|
||||
@@ -432,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)]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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<dyn UnifiedEvent>) {
|
||||
|event: Box<dyn UnifiedEvent>| {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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<dyn UnifiedEvent>) {
|
||||
|event: Box<dyn UnifiedEvent>| {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -6,6 +6,8 @@ Shows two client-construction patterns: `TradingClient::new` for one wallet and
|
||||
|
||||
This is a configuration template. Set `PRIVATE_KEY`, RPC, and every enabled provider credential; it initializes network clients but does not submit a trade.
|
||||
|
||||
The Glaive entry uses QUIC by default. Its credential must be a UUID v4 API key from Glaive. Replace the fourth argument with `Some(SwqosTransport::Http)` to use binary HTTP instead.
|
||||
|
||||
```bash
|
||||
cargo run --package trading_client
|
||||
```
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
这是配置模板。运行前设置 `PRIVATE_KEY`、RPC 和所有已启用服务商凭证;程序会初始化网络客户端,但不会提交交易。
|
||||
|
||||
Glaive 配置默认使用 QUIC,其凭证必须是 Glaive 提供的 UUID v4 API key。若要改用 binary HTTP,把第四个参数替换为 `Some(SwqosTransport::Http)`。
|
||||
|
||||
```bash
|
||||
cargo run --package trading_client
|
||||
```
|
||||
|
||||
@@ -55,6 +55,20 @@ async fn create_trading_client_simple() -> AnyResult<TradingClient> {
|
||||
), // QUIC;None / Some(Binary) / Some(Plain) 为 HTTP
|
||||
// Helius Sender: 4th param swqos_only Some(true) => min tip 0.000005 SOL; None => 0.0002 SOL
|
||||
SwqosConfig::Helius("".to_string(), SwqosRegion::Default, None, Some(true)),
|
||||
// Glaive defaults to persistent QUIC (UDP/4000). Use Some(Http) for binary HTTP.
|
||||
SwqosConfig::Glaive(
|
||||
"your_glaive_uuid_v4_api_key".to_string(),
|
||||
SwqosRegion::Frankfurt,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
// HTTP alternative:
|
||||
// SwqosConfig::Glaive(
|
||||
// "your_glaive_uuid_v4_api_key".to_string(),
|
||||
// SwqosRegion::Frankfurt,
|
||||
// None,
|
||||
// Some(sol_trade_sdk::SwqosTransport::Http),
|
||||
// ),
|
||||
];
|
||||
|
||||
let trade_config = TradeConfig::builder(rpc_url, swqos_configs, commitment)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+6
-5
@@ -13,8 +13,8 @@ pub struct InfrastructureConfig {
|
||||
/// When true, SWQOS sender threads use the *last* N cores instead of the first N. Reduces contention with main thread / default tokio workers that often use low-numbered cores. Default false.
|
||||
pub swqos_cores_from_end: bool,
|
||||
/// Global MEV protection flag. When true, SWQOS providers that support MEV protection
|
||||
/// (Astralane QUIC `:9000` or HTTP `mev-protect=true`, BlockRazor) use MEV-protected
|
||||
/// endpoints/modes. Default false.
|
||||
/// (Astralane, BlockRazor, Glaive) use MEV-protected endpoints/modes. Glaive HTTP adds
|
||||
/// `mev-protect=true`; Glaive QUIC sets auth-frame flag bit 0. Default false.
|
||||
pub mev_protection: bool,
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ pub struct TradeConfig {
|
||||
/// When true, SWQOS uses the *last* N cores (instead of the first N). Use when main thread / tokio use low-numbered cores to reduce CPU contention. Default false.
|
||||
pub swqos_cores_from_end: bool,
|
||||
/// Global MEV protection flag. When true, SWQOS providers that support MEV protection
|
||||
/// (Astralane QUIC `:9000` or Plain/Binary HTTP `mev-protect=true`, BlockRazor sandwichMitigation)
|
||||
/// use their MEV-protected endpoints/modes. Default false (no MEV protection, lower latency).
|
||||
/// (Astralane, BlockRazor, Glaive) use their MEV-protected endpoints/modes. Glaive HTTP
|
||||
/// adds `mev-protect=true`; Glaive QUIC sets auth-frame flag bit 0. Default false.
|
||||
pub mev_protection: bool,
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ impl TradeConfig {
|
||||
/// - `.log_enabled(bool)` — SDK timing/SWQOS logs (default: true)
|
||||
/// - `.check_min_tip(bool)` — filter SWQOS below min tip (default: false)
|
||||
/// - `.swqos_cores_from_end(bool)` — bind SWQOS to last N cores (default: false)
|
||||
/// - `.mev_protection(bool)` — MEV protection for Astralane/BlockRazor (default: false)
|
||||
/// - `.mev_protection(bool)` — MEV protection for Astralane/BlockRazor/Glaive (default: false)
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,ignore
|
||||
@@ -209,6 +209,7 @@ impl TradeConfigBuilder {
|
||||
/// Enable global MEV protection. When `true`:
|
||||
/// - **Astralane QUIC** uses port `9000`; **Astralane HTTP** adds `mev-protect=true`
|
||||
/// - **BlockRazor** uses `mode=sandwichMitigation` (skips blacklisted Leader slots)
|
||||
/// - **Glaive HTTP** adds `mev-protect=true`; **Glaive QUIC** sets auth-frame flag bit 0
|
||||
///
|
||||
/// May reduce landing speed. Default: `false`.
|
||||
pub fn mev_protection(mut self, v: bool) -> Self {
|
||||
|
||||
@@ -198,6 +198,16 @@ pub const LUNARLANDER_TIP_ACCOUNTS: &[Pubkey] = &[
|
||||
pubkey!("moonZ6u9E2fgk6eWd82621eLPHt9zuJuYECXAYjMY1C"),
|
||||
];
|
||||
|
||||
/// Glaive tip accounts from <https://glaive.trade/docs#tip-accounts>.
|
||||
pub const GLAIVE_TIP_ACCOUNTS: &[Pubkey] = &[
|
||||
pubkey!("GLaiv4GMRYQmthatDS98uQT4HoucgxWT8NeJz6oSwxeU"),
|
||||
pubkey!("GLaivL5uPrDpvd1wTtvat38KGqb5WLhEdqQfnmNd3oNr"),
|
||||
pubkey!("GLaivinAWh21NaJMhtExtD5G2gZs1xnvaYVZmwqobWZL"),
|
||||
pubkey!("GLaivJSUL71FcocYa8tks5vpVyYzvaDMHtyrzfQF2ABr"),
|
||||
pubkey!("GLaivRU6eDKrta3p3psFAWPEFLzCjeMHGpPUuQqTjtyv"),
|
||||
pubkey!("GLaivq5dU8qHayz9Qf13LjPfVy3SmUhbmickfGiZdmfh"),
|
||||
];
|
||||
|
||||
// `SwqosRegion` 与下列各 `SWQOS_ENDPOINTS_*` 下标严格对应(共 10 项):
|
||||
// 0 NewYork, 1 Frankfurt, 2 Amsterdam, 3 Dublin, 4 SLC, 5 Tokyo, 6 Singapore, 7 London, 8 LosAngeles, 9 Default。
|
||||
//
|
||||
@@ -477,6 +487,35 @@ pub const SWQOS_ENDPOINTS_LUNARLANDER_QUIC: [&str; 10] = [
|
||||
"nyc-1.prod.lunar-lander.hellomoon.io:16888", // Default -> NYC
|
||||
];
|
||||
|
||||
/// Glaive binary HTTP origins. The client appends `/binary?api-key=...`.
|
||||
/// Glaive currently publishes Amsterdam, Frankfurt, London, and New York PoPs.
|
||||
pub const SWQOS_ENDPOINTS_GLAIVE: [&str; 10] = [
|
||||
"http://ny.glaive.trade",
|
||||
"http://fra.glaive.trade",
|
||||
"http://ams1.glaive.trade",
|
||||
"http://lon.glaive.trade", // Dublin -> London
|
||||
"http://ny.glaive.trade", // SLC -> only published US PoP
|
||||
"http://ams1.glaive.trade", // Tokyo -> nearest published PoP
|
||||
"http://fra.glaive.trade", // Singapore -> nearest published PoP
|
||||
"http://lon.glaive.trade",
|
||||
"http://ny.glaive.trade", // Los Angeles -> only published US PoP
|
||||
"http://ams1.glaive.trade", // Default -> official documentation example
|
||||
];
|
||||
|
||||
/// Glaive QUIC endpoints. ALPN `solana-tpu`, SNI `glaive-intake`, UDP port 4000.
|
||||
pub const SWQOS_ENDPOINTS_GLAIVE_QUIC: [&str; 10] = [
|
||||
"ny.glaive.trade:4000",
|
||||
"fra.glaive.trade:4000",
|
||||
"ams1.glaive.trade:4000",
|
||||
"lon.glaive.trade:4000", // Dublin -> London
|
||||
"ny.glaive.trade:4000", // SLC -> only published US PoP
|
||||
"ams1.glaive.trade:4000", // Tokyo -> nearest published PoP
|
||||
"fra.glaive.trade:4000", // Singapore -> nearest published PoP
|
||||
"lon.glaive.trade:4000",
|
||||
"ny.glaive.trade:4000", // Los Angeles -> only published US PoP
|
||||
"ams1.glaive.trade:4000", // Default -> official documentation example
|
||||
];
|
||||
|
||||
/// Helius Sender: POST /fast, dual routing to validators and Jito. API key optional (custom TPS only).
|
||||
pub const SWQOS_ENDPOINTS_HELIUS: [&str; 10] = [
|
||||
"http://ewr-sender.helius-rpc.com/fast",
|
||||
@@ -522,6 +561,8 @@ pub const SWQOS_MIN_TIP_SPEEDLANDING: f64 = 0.001; // Speedlanding requires mini
|
||||
pub const SWQOS_MIN_TIP_HELIUS: f64 = 0.0002;
|
||||
pub const SWQOS_MIN_TIP_SOLAMI: f64 = 0.0001;
|
||||
pub const SWQOS_MIN_TIP_LUNARLANDER: f64 = 0.001; // LunarLander 最低小费 0.001 SOL
|
||||
/// Glaive requires at least 100,000 lamports (0.0001 SOL).
|
||||
pub const SWQOS_MIN_TIP_GLAIVE: f64 = 0.0001;
|
||||
/// Helius Sender with swqos_only: minimum 0.000005 SOL (much lower tip allowed).
|
||||
pub const SWQOS_MIN_TIP_HELIUS_SWQOS_ONLY: f64 = 0.000005;
|
||||
#[cfg(test)]
|
||||
@@ -549,6 +590,8 @@ mod tests {
|
||||
&SWQOS_ENDPOINTS_HELIUS,
|
||||
&SWQOS_ENDPOINTS_LUNARLANDER,
|
||||
&SWQOS_ENDPOINTS_LUNARLANDER_QUIC,
|
||||
&SWQOS_ENDPOINTS_GLAIVE,
|
||||
&SWQOS_ENDPOINTS_GLAIVE_QUIC,
|
||||
&SWQOS_ENDPOINTS_SOLAMI,
|
||||
];
|
||||
|
||||
@@ -593,4 +636,21 @@ mod tests {
|
||||
assert_eq!(plain, binary, "Astralane Plain vs Binary base URL mismatch at index {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glaive_http_and_quic_hosts_match_per_region() {
|
||||
for i in 0..10 {
|
||||
let http_host =
|
||||
SWQOS_ENDPOINTS_GLAIVE[i].strip_prefix("http://").expect("Glaive HTTP URL");
|
||||
let quic_host =
|
||||
SWQOS_ENDPOINTS_GLAIVE_QUIC[i].strip_suffix(":4000").expect("Glaive QUIC endpoint");
|
||||
assert_eq!(http_host, quic_host, "Glaive HTTP vs QUIC host mismatch at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glaive_tip_policy_matches_official_docs() {
|
||||
assert_eq!(GLAIVE_TIP_ACCOUNTS.len(), 6);
|
||||
assert_eq!(SWQOS_MIN_TIP_GLAIVE, 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
+343
-45
@@ -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<AccountMeta>,
|
||||
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<Vec<Instruction>> {
|
||||
@@ -49,12 +81,17 @@ 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;
|
||||
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 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;
|
||||
@@ -81,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(
|
||||
@@ -97,9 +147,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 +159,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))
|
||||
};
|
||||
@@ -138,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
|
||||
@@ -206,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() {
|
||||
@@ -224,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)
|
||||
@@ -284,6 +342,8 @@ impl InstructionBuilder for PumpSwapInstructionBuilder {
|
||||
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 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 +365,11 @@ 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"));
|
||||
}
|
||||
if params.fixed_output_amount == Some(0) {
|
||||
return Err(anyhow!("Fixed output amount cannot be zero"));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -314,12 +377,26 @@ 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 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 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 {
|
||||
let result = sell_base_input_internal_with_fees(
|
||||
@@ -327,9 +404,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 +416,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)
|
||||
};
|
||||
@@ -353,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(
|
||||
@@ -416,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)
|
||||
@@ -439,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(
|
||||
@@ -535,6 +636,28 @@ 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,
|
||||
crate::constants::TOKEN_PROGRAM,
|
||||
accounts::PROTOCOL_FEE_RECIPIENT,
|
||||
Pubkey::default(),
|
||||
false,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -604,16 +727,164 @@ 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();
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"PumpSwap exact-output sell is unsupported when the pool requires a sell instruction"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pumpswap_reverse_sell_fixed_output_uses_current_buy_layout() {
|
||||
let mut params = swap_params(TradeType::Sell, Some(42));
|
||||
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::SELL_DISCRIMINATOR);
|
||||
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);
|
||||
assert_eq!(u64::from_le_bytes(ix.data[16..24].try_into().unwrap()), 42);
|
||||
|
||||
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]
|
||||
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]
|
||||
@@ -627,6 +898,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 +925,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();
|
||||
@@ -668,9 +941,34 @@ mod tests {
|
||||
100,
|
||||
1_000_000_000,
|
||||
2_000_000_000,
|
||||
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_000_000_000,
|
||||
500_000_000,
|
||||
&crate::instruction::utils::pumpswap::PumpSwapFeeBasisPoints::new(20, 5, 0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(min_quote_amount_out, expected.min_quote);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -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<u32> {
|
||||
}
|
||||
|
||||
fn decode_global_config(data: &[u8]) -> Option<GlobalConfig> {
|
||||
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<Vec<PumpSwapFeeTi
|
||||
}
|
||||
|
||||
pub fn decode_fee_config(data: &[u8]) -> Option<PumpSwapFeeConfig> {
|
||||
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<GlobalConfi
|
||||
}
|
||||
};
|
||||
|
||||
if account.owner != accounts::AMM_PROGRAM {
|
||||
warn!(
|
||||
target: "pumpswap_global_config",
|
||||
owner = %account.owner,
|
||||
"PumpSwap GlobalConfig owner 无效"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let Some(config) = decode_global_config(&account.data) else {
|
||||
warn!(
|
||||
target: "pumpswap_global_config",
|
||||
@@ -435,6 +451,14 @@ async fn refresh_fee_config_once(rpc: &SolanaRpcClient) -> Option<PumpSwapFeeCon
|
||||
}
|
||||
};
|
||||
|
||||
if account.owner != accounts::FEE_PROGRAM {
|
||||
warn!(
|
||||
target: "pumpswap_fee_config",
|
||||
owner = %account.owner,
|
||||
"PumpSwap FeeConfig owner 无效"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let Some(config) = decode_fee_config(&account.data) else {
|
||||
warn!(
|
||||
target: "pumpswap_fee_config",
|
||||
@@ -665,22 +689,30 @@ pub(crate) fn coin_creator_vault_authority(coin_creator: Pubkey) -> 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,19 +765,32 @@ pub async fn fetch_pool(
|
||||
pool_address: &Pubkey,
|
||||
) -> Result<Pool, anyhow::Error> {
|
||||
let account = rpc.get_account(pool_address).await?;
|
||||
if account.owner != accounts::AMM_PROGRAM {
|
||||
return Err(anyhow!("Account is not owned by PumpSwap program"));
|
||||
}
|
||||
let pool = pool_decode(&account.data[8..]).ok_or_else(|| anyhow!("Failed to decode pool"))?;
|
||||
Ok(pool)
|
||||
decode_pool_account(&account).map_err(anyhow::Error::msg)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
fn decode_pool_account(account: &solana_sdk::account::Account) -> Result<Pool, String> {
|
||||
if account.owner != accounts::AMM_PROGRAM {
|
||||
return Err("Account is not owned by PumpSwap program".to_string());
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
/// Run getProgramAccounts with a Memcmp filter, querying both pool sizes in parallel.
|
||||
async fn get_program_accounts_both_sizes(
|
||||
/// 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 = 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.
|
||||
async fn get_program_accounts_known_sizes(
|
||||
rpc: &SolanaRpcClient,
|
||||
memcmp_offset: usize,
|
||||
mint: &Pubkey,
|
||||
@@ -753,6 +798,9 @@ async fn get_program_accounts_both_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()),
|
||||
),
|
||||
@@ -768,12 +816,28 @@ 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, 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 = spl_result.unwrap_or_default();
|
||||
all.extend(t22_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)
|
||||
}
|
||||
|
||||
@@ -782,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()
|
||||
}
|
||||
|
||||
@@ -797,7 +855,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 +872,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));
|
||||
}
|
||||
@@ -878,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<u64, anyhow::Error> {
|
||||
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<PoolRpcSnapshot, anyhow::Error> {
|
||||
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::<u64>().map_err(|e| anyhow!(e))?;
|
||||
let quote_amount = quote_balance.amount.parse::<u64>().map_err(|e| anyhow!(e))?;
|
||||
|
||||
Ok((base_amount, quote_amount))
|
||||
Ok((snapshot.base_reserve, snapshot.quote_reserve))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -902,7 +1054,40 @@ pub fn get_fee_config_pda() -> Option<Pubkey> {
|
||||
#[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 {
|
||||
@@ -929,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();
|
||||
@@ -986,4 +1190,78 @@ 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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -17,16 +19,135 @@ 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_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 {
|
||||
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<LegacyPool> 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<Pool> {
|
||||
if data.len() < POOL_SIZE {
|
||||
return None;
|
||||
if data.len() >= POOL_SIZE {
|
||||
return borsh::from_slice::<Pool>(&data[..POOL_SIZE]).ok();
|
||||
}
|
||||
|
||||
if data.len() == LEGACY_POOL_SIZE {
|
||||
return borsh::from_slice::<LegacyPool>(&data[..LEGACY_POOL_FIELDS_SIZE])
|
||||
.ok()
|
||||
.map(Into::into);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Compute the quote reserves used by PumpSwap pricing.
|
||||
///
|
||||
/// 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,
|
||||
virtual_quote_reserves: i128,
|
||||
) -> Option<u64> {
|
||||
i128::from(quote_vault_balance)
|
||||
.checked_add(virtual_quote_reserves)
|
||||
.and_then(|reserves| u64::try_from(reserves).ok())
|
||||
.filter(|reserves| *reserves != 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pool_payload(virtual_quote_reserves: i128) -> Vec<u8> {
|
||||
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_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));
|
||||
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);
|
||||
}
|
||||
borsh::from_slice::<Pool>(&data[..POOL_SIZE]).ok()
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ pub mod trading;
|
||||
pub mod utils;
|
||||
|
||||
pub use crate::common::nonce_cache::{fetch_nonce_info, DurableNonceInfo};
|
||||
// Re-export for SwqosConfig (Node1/BlockRazor transport; Astralane submission mode)
|
||||
// Re-export transport selectors used by SWQoS configs (including Glaive).
|
||||
pub use crate::swqos::{AstralaneTransport, SwqosTransport};
|
||||
pub use client::{
|
||||
find_pool_by_mint, recommended_sender_thread_core_indices, AccountPolicy, BuyAmount,
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use parking_lot::Mutex;
|
||||
use rand::seq::IndexedRandom as _;
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use serde_json::Value;
|
||||
use solana_sdk::{signature::Signature, transaction::VersionedTransaction};
|
||||
use std::{
|
||||
str::FromStr as _,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
common::SolanaRpcClient,
|
||||
constants::swqos::GLAIVE_TIP_ACCOUNTS,
|
||||
swqos::{
|
||||
common::{default_http_client_builder, poll_transaction_confirmation},
|
||||
glaive_quic::GlaiveQuicClient,
|
||||
serialization::serialize_transaction_bincode_sync,
|
||||
SwqosClientTrait, SwqosType, TradeType,
|
||||
},
|
||||
};
|
||||
|
||||
const HTTP_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(20);
|
||||
|
||||
enum GlaiveBackend {
|
||||
Http {
|
||||
submit_url: Url,
|
||||
health_url: Url,
|
||||
http_client: Client,
|
||||
stop_ping: Arc<AtomicBool>,
|
||||
ping_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
},
|
||||
Quic(Arc<GlaiveQuicClient>),
|
||||
}
|
||||
|
||||
pub struct GlaiveClient {
|
||||
rpc_client: Arc<SolanaRpcClient>,
|
||||
backend: GlaiveBackend,
|
||||
}
|
||||
|
||||
impl GlaiveClient {
|
||||
pub fn new_http(
|
||||
rpc_url: String,
|
||||
endpoint: String,
|
||||
api_key: String,
|
||||
mev_protection: bool,
|
||||
) -> Result<Self> {
|
||||
validate_api_key(&api_key)?;
|
||||
let submit_url = build_binary_url(&endpoint, &api_key, mev_protection)?;
|
||||
let health_url = build_health_url(&endpoint)?;
|
||||
let http_client = default_http_client_builder().build()?;
|
||||
let stop_ping = Arc::new(AtomicBool::new(false));
|
||||
let ping_handle = Mutex::new(None);
|
||||
|
||||
let client = Self {
|
||||
rpc_client: Arc::new(SolanaRpcClient::new(rpc_url)),
|
||||
backend: GlaiveBackend::Http {
|
||||
submit_url,
|
||||
health_url,
|
||||
http_client,
|
||||
stop_ping,
|
||||
ping_handle,
|
||||
},
|
||||
};
|
||||
client.start_http_keep_alive();
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub async fn new_quic(
|
||||
rpc_url: String,
|
||||
endpoint: &str,
|
||||
api_key: String,
|
||||
mev_protection: bool,
|
||||
) -> Result<Self> {
|
||||
let quic = GlaiveQuicClient::connect(endpoint, &api_key, mev_protection).await?;
|
||||
Ok(Self {
|
||||
rpc_client: Arc::new(SolanaRpcClient::new(rpc_url)),
|
||||
backend: GlaiveBackend::Quic(Arc::new(quic)),
|
||||
})
|
||||
}
|
||||
|
||||
fn start_http_keep_alive(&self) {
|
||||
let GlaiveBackend::Http { health_url, http_client, stop_ping, ping_handle, .. } =
|
||||
&self.backend
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let health_url = health_url.clone();
|
||||
let http_client = http_client.clone();
|
||||
let stop_ping = Arc::clone(stop_ping);
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
let handle = runtime.spawn(async move {
|
||||
let mut interval = tokio::time::interval(HTTP_KEEP_ALIVE_INTERVAL);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if stop_ping.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if let Err(error) = send_health_request(&http_client, health_url.clone()).await {
|
||||
if crate::common::sdk_log::sdk_log_enabled() {
|
||||
eprintln!(" [Glaive] HTTP keep-alive failed: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
*ping_handle.lock() = Some(handle);
|
||||
}
|
||||
|
||||
async fn send_transaction_impl(
|
||||
&self,
|
||||
trade_type: TradeType,
|
||||
transaction: &VersionedTransaction,
|
||||
wait_confirmation: bool,
|
||||
) -> Result<()> {
|
||||
let submit_started = Instant::now();
|
||||
let (serialized, signature) = serialize_transaction_bincode_sync(transaction)?;
|
||||
|
||||
let submit_result = match &self.backend {
|
||||
GlaiveBackend::Http { submit_url, http_client, .. } => {
|
||||
let response = http_client
|
||||
.post(submit_url.clone())
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.body(serialized.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
anyhow::anyhow!("Glaive HTTP request failed: {}", error.without_url())
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("read Glaive response: {error}"))?;
|
||||
parse_binary_response(status, &body, signature)
|
||||
}
|
||||
GlaiveBackend::Quic(quic) => quic.send_transaction(&serialized).await,
|
||||
};
|
||||
|
||||
if let Err(error) = submit_result {
|
||||
if crate::common::sdk_log::sdk_log_enabled() {
|
||||
crate::common::sdk_log::log_swqos_submission_failed(
|
||||
"Glaive",
|
||||
trade_type,
|
||||
submit_started.elapsed(),
|
||||
&error,
|
||||
);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if crate::common::sdk_log::sdk_log_enabled() {
|
||||
crate::common::sdk_log::log_swqos_submitted(
|
||||
"Glaive",
|
||||
trade_type,
|
||||
submit_started.elapsed(),
|
||||
);
|
||||
}
|
||||
|
||||
let confirm_started = Instant::now();
|
||||
if let Err(error) =
|
||||
poll_transaction_confirmation(&self.rpc_client, signature, wait_confirmation).await
|
||||
{
|
||||
if crate::common::sdk_log::sdk_log_enabled() {
|
||||
crate::common::sdk_log::log_swqos_submission_failed(
|
||||
"Glaive",
|
||||
trade_type,
|
||||
confirm_started.elapsed(),
|
||||
&error,
|
||||
);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if wait_confirmation && crate::common::sdk_log::sdk_log_enabled() {
|
||||
println!(" signature: {signature:?}");
|
||||
println!(
|
||||
" [{:width$}] {} confirmed: {:?}",
|
||||
"Glaive",
|
||||
trade_type,
|
||||
confirm_started.elapsed(),
|
||||
width = crate::common::sdk_log::SWQOS_LABEL_WIDTH
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SwqosClientTrait for GlaiveClient {
|
||||
async fn send_transaction(
|
||||
&self,
|
||||
trade_type: TradeType,
|
||||
transaction: &VersionedTransaction,
|
||||
wait_confirmation: bool,
|
||||
) -> Result<()> {
|
||||
self.send_transaction_impl(trade_type, transaction, wait_confirmation).await
|
||||
}
|
||||
|
||||
async fn send_transactions(
|
||||
&self,
|
||||
trade_type: TradeType,
|
||||
transactions: &Vec<VersionedTransaction>,
|
||||
wait_confirmation: bool,
|
||||
) -> Result<()> {
|
||||
for transaction in transactions {
|
||||
self.send_transaction_impl(trade_type, transaction, wait_confirmation).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_tip_account(&self) -> Result<String> {
|
||||
let tip_account = *GLAIVE_TIP_ACCOUNTS
|
||||
.choose(&mut rand::rng())
|
||||
.or_else(|| GLAIVE_TIP_ACCOUNTS.first())
|
||||
.context("Glaive tip account list is empty")?;
|
||||
Ok(tip_account.to_string())
|
||||
}
|
||||
|
||||
fn get_swqos_type(&self) -> SwqosType {
|
||||
SwqosType::Glaive
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlaiveClient {
|
||||
fn drop(&mut self) {
|
||||
if let GlaiveBackend::Http { stop_ping, ping_handle, .. } = &self.backend {
|
||||
stop_ping.store(true, Ordering::Relaxed);
|
||||
if let Some(handle) = ping_handle.lock().take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_api_key(api_key: &str) -> Result<()> {
|
||||
let key = Uuid::parse_str(api_key.trim()).context("Glaive API key must be a valid UUID v4")?;
|
||||
if key.get_version_num() != 4 {
|
||||
anyhow::bail!("Glaive API key must be a UUID v4");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_endpoint(endpoint: &str) -> Result<Url> {
|
||||
Url::parse(endpoint).context("Glaive HTTP endpoint must be an absolute http(s) URL")
|
||||
}
|
||||
|
||||
fn build_binary_url(endpoint: &str, api_key: &str, mev_protection: bool) -> Result<Url> {
|
||||
let mut url = parse_endpoint(endpoint)?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
anyhow::bail!("Glaive HTTP endpoint must use http or https");
|
||||
}
|
||||
|
||||
let path = url.path().trim_end_matches('/');
|
||||
if !path.ends_with("/binary") {
|
||||
let path = if path.is_empty() { "/binary".to_string() } else { format!("{path}/binary") };
|
||||
url.set_path(&path);
|
||||
}
|
||||
let existing_query: Vec<(String, String)> = url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| key != "api-key" && key != "mev-protect")
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
url.set_query(None);
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.extend_pairs(existing_query);
|
||||
query.append_pair("api-key", api_key.trim());
|
||||
if mev_protection {
|
||||
query.append_pair("mev-protect", "true");
|
||||
}
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn build_health_url(endpoint: &str) -> Result<Url> {
|
||||
let mut url = parse_endpoint(endpoint)?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
anyhow::bail!("Glaive HTTP endpoint must use http or https");
|
||||
}
|
||||
url.set_path("/health");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn send_health_request(client: &Client, health_url: Url) -> Result<()> {
|
||||
let response =
|
||||
client.get(health_url).timeout(Duration::from_millis(1500)).send().await.map_err(
|
||||
|error| anyhow::anyhow!("Glaive health request failed: {}", error.without_url()),
|
||||
)?;
|
||||
let status = response.status();
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("read Glaive health response: {}", error.without_url()))?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Glaive health request returned HTTP {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_binary_response(status: StatusCode, body: &[u8], expected: Signature) -> Result<()> {
|
||||
let json: Value = serde_json::from_slice(body).with_context(|| {
|
||||
format!("Glaive returned HTTP {status} with invalid JSON: {}", bounded_body(body))
|
||||
})?;
|
||||
|
||||
if let Some(error) = json.get("error") {
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| error.as_str())
|
||||
.unwrap_or("unknown Glaive error");
|
||||
let code = error.get("code").and_then(Value::as_i64);
|
||||
if let Some(code) = code {
|
||||
anyhow::bail!("Glaive rejected transaction: code={code} message={message}");
|
||||
}
|
||||
anyhow::bail!("Glaive rejected transaction: {message}");
|
||||
}
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Glaive returned HTTP {status}: {}", bounded_body(body));
|
||||
}
|
||||
|
||||
let result = json
|
||||
.get("result")
|
||||
.and_then(Value::as_str)
|
||||
.context("Glaive response missing result signature")?;
|
||||
let returned = Signature::from_str(result).context("Glaive returned an invalid signature")?;
|
||||
if returned != expected {
|
||||
anyhow::bail!("Glaive returned a signature that does not match the submitted transaction");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bounded_body(body: &[u8]) -> String {
|
||||
String::from_utf8_lossy(body).chars().take(512).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_UUID: &str = "00112233-4455-4677-8899-aabbccddeeff";
|
||||
|
||||
#[test]
|
||||
fn binary_url_uses_official_query_names() {
|
||||
let url = build_binary_url("http://fra.glaive.trade", TEST_UUID, true).unwrap();
|
||||
assert_eq!(url.path(), "/binary");
|
||||
let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
assert_eq!(query.get("api-key").map(String::as_str), Some(TEST_UUID));
|
||||
assert_eq!(query.get("mev-protect").map(String::as_str), Some("true"));
|
||||
assert!(!query.contains_key("key"));
|
||||
assert!(!query.contains_key("mev_protect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_binary_url_is_not_duplicated_and_health_is_rooted() {
|
||||
let url = build_binary_url(
|
||||
"https://custom.example/glaive/binary?existing=1&api-key=stale&mev-protect=true",
|
||||
TEST_UUID,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(url.path(), "/glaive/binary");
|
||||
assert!(url.query_pairs().any(|(key, value)| key == "existing" && value == "1"));
|
||||
assert!(!url.query_pairs().any(|(key, _)| key == "mev-protect"));
|
||||
let keys: Vec<_> = url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| key == "api-key")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
.collect();
|
||||
assert_eq!(keys, [TEST_UUID]);
|
||||
|
||||
let health = build_health_url("https://custom.example/glaive/binary?existing=1").unwrap();
|
||||
assert_eq!(health.as_str(), "https://custom.example/health");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_response_requires_matching_signature() {
|
||||
let signature = Signature::new_unique();
|
||||
let success = format!(r#"{{"result":"{signature}"}}"#);
|
||||
assert!(parse_binary_response(StatusCode::OK, success.as_bytes(), signature).is_ok());
|
||||
|
||||
let mismatch = format!(r#"{{"result":"{}"}}"#, Signature::new_unique());
|
||||
assert!(parse_binary_response(StatusCode::OK, mismatch.as_bytes(), signature).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_response_surfaces_http_and_rpc_errors() {
|
||||
let signature = Signature::new_unique();
|
||||
let rpc_error = br#"{"error":{"code":-32000,"message":"missing tip"}}"#;
|
||||
let error =
|
||||
parse_binary_response(StatusCode::OK, rpc_error, signature).unwrap_err().to_string();
|
||||
assert!(error.contains("-32000"));
|
||||
assert!(error.contains("missing tip"));
|
||||
|
||||
let http_error = br#"{"error":"rate limit exceeded"}"#;
|
||||
let error = parse_binary_response(StatusCode::TOO_MANY_REQUESTS, http_error, signature)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("rate limit exceeded"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Glaive's persistent QUIC submission transport.
|
||||
//!
|
||||
//! Protocol reference: <https://glaive.trade/docs#send-quic>
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use arc_swap::ArcSwap;
|
||||
use quinn::{
|
||||
crypto::rustls::QuicClientConfig, ClientConfig, Connection, Endpoint, IdleTimeout,
|
||||
TransportConfig,
|
||||
};
|
||||
use solana_sdk::signature::Keypair;
|
||||
use solana_tls_utils::{new_dummy_x509_certificate, SkipServerVerification};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs as _},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{sync::Mutex, time::timeout};
|
||||
use uuid::Uuid;
|
||||
|
||||
const ALPN_TPU_PROTOCOL_ID: &[u8] = b"solana-tpu";
|
||||
const GLAIVE_QUIC_SNI: &str = "glaive-intake";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SEND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MEV_PROTECT_BIT: u8 = 1 << 0;
|
||||
pub const MAX_TRANSACTION_SIZE: usize = 1232;
|
||||
|
||||
pub struct GlaiveQuicClient {
|
||||
endpoint: Endpoint,
|
||||
client_config: ClientConfig,
|
||||
server_addr: SocketAddr,
|
||||
auth_frame: [u8; 17],
|
||||
connection: ArcSwap<Connection>,
|
||||
reconnect: Mutex<()>,
|
||||
}
|
||||
|
||||
impl GlaiveQuicClient {
|
||||
pub async fn connect(
|
||||
endpoint_addr: &str,
|
||||
api_key_uuid: &str,
|
||||
mev_protection: bool,
|
||||
) -> Result<Self> {
|
||||
let auth_frame = build_auth_frame(api_key_uuid, mev_protection)?;
|
||||
let client_config = build_client_config()?;
|
||||
let server_addr = resolve_endpoint(endpoint_addr)?;
|
||||
|
||||
let mut endpoint = Endpoint::client(local_bind_addr(server_addr))
|
||||
.context("create Glaive QUIC endpoint")?;
|
||||
endpoint.set_default_client_config(client_config.clone());
|
||||
|
||||
let connection = connect_once(&endpoint, &client_config, server_addr).await?;
|
||||
authenticate(&connection, &auth_frame).await?;
|
||||
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
client_config,
|
||||
server_addr,
|
||||
auth_frame,
|
||||
connection: ArcSwap::from_pointee(connection),
|
||||
reconnect: Mutex::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_transaction(&self, transaction_bytes: &[u8]) -> Result<()> {
|
||||
validate_transaction_size(transaction_bytes)?;
|
||||
|
||||
let stale = self.connection.load_full();
|
||||
match send_once(&stale, transaction_bytes).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first_error) => {
|
||||
let connection = self.reconnect_if_stale(&stale).await.with_context(|| {
|
||||
format!("Glaive QUIC reconnect after send failure: {first_error}")
|
||||
})?;
|
||||
send_once(&connection, transaction_bytes)
|
||||
.await
|
||||
.context("Glaive QUIC send failed after reconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconnect_if_stale(&self, stale: &Arc<Connection>) -> Result<Arc<Connection>> {
|
||||
let _guard = self.reconnect.lock().await;
|
||||
let current = self.connection.load_full();
|
||||
if !Arc::ptr_eq(¤t, stale) && current.close_reason().is_none() {
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
let connection =
|
||||
connect_once(&self.endpoint, &self.client_config, self.server_addr).await?;
|
||||
authenticate(&connection, &self.auth_frame).await?;
|
||||
self.connection.store(Arc::new(connection));
|
||||
Ok(self.connection.load_full())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlaiveQuicClient {
|
||||
fn drop(&mut self) {
|
||||
self.connection.load_full().close(0u32.into(), b"client closing");
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_once(
|
||||
endpoint: &Endpoint,
|
||||
client_config: &ClientConfig,
|
||||
server_addr: SocketAddr,
|
||||
) -> Result<Connection> {
|
||||
let connecting = endpoint
|
||||
.connect_with(client_config.clone(), server_addr, GLAIVE_QUIC_SNI)
|
||||
.context("start Glaive QUIC handshake")?;
|
||||
timeout(CONNECT_TIMEOUT, connecting)
|
||||
.await
|
||||
.context("Glaive QUIC connect timeout")?
|
||||
.context("Glaive QUIC handshake failed")
|
||||
}
|
||||
|
||||
async fn authenticate(connection: &Connection, auth_frame: &[u8; 17]) -> Result<()> {
|
||||
timeout(AUTH_TIMEOUT, async {
|
||||
let mut stream = connection.open_uni().await.context("open Glaive auth stream")?;
|
||||
stream.write_all(auth_frame).await.context("write Glaive auth frame")?;
|
||||
stream.finish().context("finish Glaive auth stream")?;
|
||||
Result::<()>::Ok(())
|
||||
})
|
||||
.await
|
||||
.context("Glaive QUIC auth timeout")??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_once(connection: &Connection, transaction_bytes: &[u8]) -> Result<()> {
|
||||
timeout(SEND_TIMEOUT, async {
|
||||
let mut stream = connection.open_uni().await.context("open Glaive transaction stream")?;
|
||||
stream.write_all(transaction_bytes).await.context("write Glaive transaction")?;
|
||||
stream.finish().context("finish Glaive transaction stream")?;
|
||||
Result::<()>::Ok(())
|
||||
})
|
||||
.await
|
||||
.context("Glaive QUIC send timeout")??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_auth_frame(api_key_uuid: &str, mev_protection: bool) -> Result<[u8; 17]> {
|
||||
let api_key =
|
||||
Uuid::parse_str(api_key_uuid.trim()).context("Glaive API key must be a valid UUID v4")?;
|
||||
if api_key.get_version_num() != 4 {
|
||||
anyhow::bail!("Glaive API key must be a UUID v4");
|
||||
}
|
||||
|
||||
let mut frame = [0u8; 17];
|
||||
frame[..16].copy_from_slice(&api_key.as_u128().to_be_bytes());
|
||||
frame[16] = if mev_protection { MEV_PROTECT_BIT } else { 0 };
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
fn validate_transaction_size(transaction_bytes: &[u8]) -> Result<()> {
|
||||
if transaction_bytes.len() > MAX_TRANSACTION_SIZE {
|
||||
anyhow::bail!(
|
||||
"Glaive QUIC transaction too large: {} bytes (max {})",
|
||||
transaction_bytes.len(),
|
||||
MAX_TRANSACTION_SIZE
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_client_config() -> Result<ClientConfig> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let keypair = Keypair::new();
|
||||
let (certificate, private_key) = new_dummy_x509_certificate(&keypair);
|
||||
|
||||
let mut crypto = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(SkipServerVerification::new())
|
||||
.with_client_auth_cert(vec![certificate], private_key)
|
||||
.context("configure Glaive QUIC client certificate")?;
|
||||
crypto.alpn_protocols = vec![ALPN_TPU_PROTOCOL_ID.to_vec()];
|
||||
|
||||
let quic_crypto = QuicClientConfig::try_from(crypto)
|
||||
.context("convert Glaive rustls config to QUIC config")?;
|
||||
let mut client_config = ClientConfig::new(Arc::new(quic_crypto));
|
||||
let mut transport = TransportConfig::default();
|
||||
transport.keep_alive_interval(Some(KEEP_ALIVE_INTERVAL));
|
||||
transport.max_idle_timeout(Some(IdleTimeout::try_from(MAX_IDLE_TIMEOUT)?));
|
||||
client_config.transport_config(Arc::new(transport));
|
||||
Ok(client_config)
|
||||
}
|
||||
|
||||
fn resolve_endpoint(endpoint_addr: &str) -> Result<SocketAddr> {
|
||||
let mut candidates: Vec<_> = endpoint_addr
|
||||
.to_socket_addrs()
|
||||
.with_context(|| format!("resolve Glaive QUIC endpoint {endpoint_addr}"))?
|
||||
.collect();
|
||||
candidates.sort_by_key(|addr| if addr.is_ipv4() { 0 } else { 1 });
|
||||
candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.with_context(|| format!("Glaive QUIC endpoint resolved to no addresses: {endpoint_addr}"))
|
||||
}
|
||||
|
||||
fn local_bind_addr(remote: SocketAddr) -> SocketAddr {
|
||||
match remote.ip() {
|
||||
IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
|
||||
IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_UUID: &str = "00112233-4455-4677-8899-aabbccddeeff";
|
||||
|
||||
#[test]
|
||||
fn auth_frame_is_big_endian_uuid_plus_flags() {
|
||||
let frame = build_auth_frame(TEST_UUID, true).unwrap();
|
||||
assert_eq!(
|
||||
&frame[..16],
|
||||
&[
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x46, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
|
||||
0xee, 0xff
|
||||
]
|
||||
);
|
||||
assert_eq!(frame[16], MEV_PROTECT_BIT);
|
||||
|
||||
let unprotected = build_auth_frame(TEST_UUID, false).unwrap();
|
||||
assert_eq!(unprotected[16], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_frame_rejects_non_v4_or_malformed_keys() {
|
||||
assert!(build_auth_frame("not-a-uuid", false).is_err());
|
||||
assert!(build_auth_frame("00112233-4455-1677-8899-aabbccddeeff", false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_size_matches_solana_packet_limit() {
|
||||
assert!(validate_transaction_size(&vec![0; MAX_TRANSACTION_SIZE]).is_ok());
|
||||
assert!(validate_transaction_size(&vec![0; MAX_TRANSACTION_SIZE + 1]).is_err());
|
||||
}
|
||||
}
|
||||
+106
-11
@@ -4,6 +4,8 @@ pub mod blockrazor;
|
||||
pub mod bloxroute;
|
||||
pub mod common;
|
||||
pub mod flashblock;
|
||||
pub mod glaive;
|
||||
pub mod glaive_quic;
|
||||
pub mod helius;
|
||||
pub mod jito;
|
||||
pub mod lightspeed;
|
||||
@@ -33,20 +35,21 @@ use crate::{
|
||||
SWQOS_ENDPOINTS_ASTRALANE_BINARY, SWQOS_ENDPOINTS_ASTRALANE_PLAIN,
|
||||
SWQOS_ENDPOINTS_ASTRALANE_QUIC, SWQOS_ENDPOINTS_ASTRALANE_QUIC_MEV,
|
||||
SWQOS_ENDPOINTS_BLOCKRAZOR, SWQOS_ENDPOINTS_BLOCKRAZOR_GRPC, SWQOS_ENDPOINTS_BLOX,
|
||||
SWQOS_ENDPOINTS_FLASHBLOCK, SWQOS_ENDPOINTS_HELIUS, SWQOS_ENDPOINTS_JITO,
|
||||
SWQOS_ENDPOINTS_LUNARLANDER, SWQOS_ENDPOINTS_LUNARLANDER_QUIC, SWQOS_ENDPOINTS_NEXTBLOCK,
|
||||
SWQOS_ENDPOINTS_NODE1, SWQOS_ENDPOINTS_NODE1_QUIC, SWQOS_ENDPOINTS_SOLAMI,
|
||||
SWQOS_ENDPOINTS_SOYAS, SWQOS_ENDPOINTS_SPEEDLANDING, SWQOS_ENDPOINTS_STELLIUM,
|
||||
SWQOS_ENDPOINTS_TEMPORAL, SWQOS_ENDPOINTS_ZERO_SLOT, SWQOS_MIN_TIP_ASTRALANE,
|
||||
SWQOS_MIN_TIP_BLOCKRAZOR, SWQOS_MIN_TIP_BLOXROUTE, SWQOS_MIN_TIP_DEFAULT,
|
||||
SWQOS_MIN_TIP_FLASHBLOCK, SWQOS_MIN_TIP_HELIUS, SWQOS_MIN_TIP_JITO,
|
||||
SWQOS_MIN_TIP_LIGHTSPEED, SWQOS_MIN_TIP_LUNARLANDER, SWQOS_MIN_TIP_NEXTBLOCK,
|
||||
SWQOS_MIN_TIP_NODE1, SWQOS_MIN_TIP_SOLAMI, SWQOS_MIN_TIP_SOYAS, SWQOS_MIN_TIP_SPEEDLANDING,
|
||||
SWQOS_ENDPOINTS_FLASHBLOCK, SWQOS_ENDPOINTS_GLAIVE, SWQOS_ENDPOINTS_GLAIVE_QUIC,
|
||||
SWQOS_ENDPOINTS_HELIUS, SWQOS_ENDPOINTS_JITO, SWQOS_ENDPOINTS_LUNARLANDER,
|
||||
SWQOS_ENDPOINTS_LUNARLANDER_QUIC, SWQOS_ENDPOINTS_NEXTBLOCK, SWQOS_ENDPOINTS_NODE1,
|
||||
SWQOS_ENDPOINTS_NODE1_QUIC, SWQOS_ENDPOINTS_SOLAMI, SWQOS_ENDPOINTS_SOYAS,
|
||||
SWQOS_ENDPOINTS_SPEEDLANDING, SWQOS_ENDPOINTS_STELLIUM, SWQOS_ENDPOINTS_TEMPORAL,
|
||||
SWQOS_ENDPOINTS_ZERO_SLOT, SWQOS_MIN_TIP_ASTRALANE, SWQOS_MIN_TIP_BLOCKRAZOR,
|
||||
SWQOS_MIN_TIP_BLOXROUTE, SWQOS_MIN_TIP_DEFAULT, SWQOS_MIN_TIP_FLASHBLOCK,
|
||||
SWQOS_MIN_TIP_GLAIVE, SWQOS_MIN_TIP_HELIUS, SWQOS_MIN_TIP_JITO, SWQOS_MIN_TIP_LIGHTSPEED,
|
||||
SWQOS_MIN_TIP_LUNARLANDER, SWQOS_MIN_TIP_NEXTBLOCK, SWQOS_MIN_TIP_NODE1,
|
||||
SWQOS_MIN_TIP_SOLAMI, SWQOS_MIN_TIP_SOYAS, SWQOS_MIN_TIP_SPEEDLANDING,
|
||||
SWQOS_MIN_TIP_STELLIUM, SWQOS_MIN_TIP_TEMPORAL, SWQOS_MIN_TIP_ZERO_SLOT,
|
||||
},
|
||||
swqos::{
|
||||
astralane::AstralaneClient, blockrazor::BlockRazorClient, bloxroute::BloxrouteClient,
|
||||
flashblock::FlashBlockClient, helius::HeliusClient, jito::JitoClient,
|
||||
flashblock::FlashBlockClient, glaive::GlaiveClient, helius::HeliusClient, jito::JitoClient,
|
||||
lightspeed::LightspeedClient, lunarlander::LunarLanderClient, nextblock::NextBlockClient,
|
||||
node1::Node1Client, node1_quic::Node1QuicClient, solami::SolamiClient,
|
||||
solana_rpc::SolRpcClient, soyas::SoyasClient, speedlanding::SpeedlandingClient,
|
||||
@@ -66,7 +69,7 @@ pub const SWQOS_BLACKLIST: &[SwqosType] = &[
|
||||
|
||||
/// SWQOS 提交通道:HTTP、gRPC 或 QUIC(低延迟)。
|
||||
/// BlockRazor 支持 gRPC 和 HTTP。
|
||||
/// Node1 支持 QUIC。
|
||||
/// Node1、Glaive 与 Lunar Lander 支持 QUIC。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum SwqosTransport {
|
||||
#[default]
|
||||
@@ -126,6 +129,7 @@ pub enum SwqosType {
|
||||
Helius,
|
||||
Solami,
|
||||
LunarLander,
|
||||
Glaive,
|
||||
Default,
|
||||
}
|
||||
|
||||
@@ -150,6 +154,7 @@ impl SwqosType {
|
||||
Self::Helius => "Helius",
|
||||
Self::Solami => "Solami",
|
||||
Self::LunarLander => "LunarLander",
|
||||
Self::Glaive => "Glaive",
|
||||
Self::Default => "Default",
|
||||
}
|
||||
}
|
||||
@@ -172,6 +177,7 @@ impl SwqosType {
|
||||
Self::Helius,
|
||||
Self::Solami,
|
||||
Self::LunarLander,
|
||||
Self::Glaive,
|
||||
Self::Default,
|
||||
]
|
||||
}
|
||||
@@ -215,6 +221,7 @@ pub trait SwqosClientTrait {
|
||||
SwqosType::Helius => SWQOS_MIN_TIP_HELIUS,
|
||||
SwqosType::Solami => SWQOS_MIN_TIP_SOLAMI,
|
||||
SwqosType::LunarLander => SWQOS_MIN_TIP_LUNARLANDER,
|
||||
SwqosType::Glaive => SWQOS_MIN_TIP_GLAIVE,
|
||||
SwqosType::Default => SWQOS_MIN_TIP_DEFAULT,
|
||||
}
|
||||
}
|
||||
@@ -281,6 +288,10 @@ pub enum SwqosConfig {
|
||||
/// (api_key, region, custom_url, transport). transport=None => QUIC; Some(Http) => HTTP.
|
||||
/// Minimum tip: 0.001 SOL. Apply for API key: https://docs.hellomoon.io/reference/lunar-lander
|
||||
LunarLander(String, SwqosRegion, Option<String>, Option<SwqosTransport>),
|
||||
/// Glaive(api_key_uuid, region, custom_url, transport).
|
||||
/// transport=None => QUIC (official lowest-latency path, UDP/4000); Some(Http) => binary HTTP.
|
||||
/// Minimum tip: 0.0001 SOL. API and protocol docs: <https://glaive.trade/docs>
|
||||
Glaive(String, SwqosRegion, Option<String>, Option<SwqosTransport>),
|
||||
}
|
||||
|
||||
impl SwqosConfig {
|
||||
@@ -303,6 +314,7 @@ impl SwqosConfig {
|
||||
SwqosConfig::Helius(_, _, _, _) => SwqosType::Helius,
|
||||
SwqosConfig::Solami(_, _, _) => SwqosType::Solami,
|
||||
SwqosConfig::LunarLander(_, _, _, _) => SwqosType::LunarLander,
|
||||
SwqosConfig::Glaive(_, _, _, _) => SwqosType::Glaive,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +345,7 @@ impl SwqosConfig {
|
||||
SwqosType::Helius => SWQOS_ENDPOINTS_HELIUS[region as usize].to_string(),
|
||||
SwqosType::Solami => SWQOS_ENDPOINTS_SOLAMI[region as usize].to_string(),
|
||||
SwqosType::LunarLander => SWQOS_ENDPOINTS_LUNARLANDER[region as usize].to_string(),
|
||||
SwqosType::Glaive => SWQOS_ENDPOINTS_GLAIVE[region as usize].to_string(),
|
||||
SwqosType::Default => "".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -374,6 +387,14 @@ impl SwqosConfig {
|
||||
SWQOS_ENDPOINTS_LUNARLANDER[region as usize].to_string()
|
||||
}
|
||||
}
|
||||
SwqosType::Glaive => {
|
||||
let use_quic = transport.unwrap_or(SwqosTransport::Quic) == SwqosTransport::Quic;
|
||||
if use_quic {
|
||||
SWQOS_ENDPOINTS_GLAIVE_QUIC[region as usize].to_string()
|
||||
} else {
|
||||
SWQOS_ENDPOINTS_GLAIVE[region as usize].to_string()
|
||||
}
|
||||
}
|
||||
_ => Self::get_endpoint(swqos_type, region, None),
|
||||
}
|
||||
}
|
||||
@@ -568,6 +589,37 @@ impl SwqosConfig {
|
||||
Ok(Arc::new(lunarlander_client))
|
||||
}
|
||||
}
|
||||
SwqosConfig::Glaive(api_key, region, url, transport) => {
|
||||
match transport.unwrap_or(SwqosTransport::Quic) {
|
||||
SwqosTransport::Quic => {
|
||||
let endpoint = url.unwrap_or_else(|| {
|
||||
SWQOS_ENDPOINTS_GLAIVE_QUIC[region as usize].to_string()
|
||||
});
|
||||
let client = GlaiveClient::new_quic(
|
||||
rpc_url.clone(),
|
||||
&endpoint,
|
||||
api_key,
|
||||
mev_protection,
|
||||
)
|
||||
.await?;
|
||||
Ok(Arc::new(client))
|
||||
}
|
||||
SwqosTransport::Http => {
|
||||
let endpoint = url
|
||||
.unwrap_or_else(|| SWQOS_ENDPOINTS_GLAIVE[region as usize].to_string());
|
||||
let client = GlaiveClient::new_http(
|
||||
rpc_url.clone(),
|
||||
endpoint,
|
||||
api_key,
|
||||
mev_protection,
|
||||
)?;
|
||||
Ok(Arc::new(client))
|
||||
}
|
||||
SwqosTransport::Grpc => {
|
||||
anyhow::bail!("Glaive does not support the gRPC transport")
|
||||
}
|
||||
}
|
||||
}
|
||||
SwqosConfig::Default(endpoint) => {
|
||||
let rpc = SolanaRpcClient::new_with_commitment(endpoint, commitment);
|
||||
let rpc_client = SolRpcClient::new(Arc::new(rpc));
|
||||
@@ -606,4 +658,47 @@ mod tests {
|
||||
|
||||
assert_eq!(endpoint, SWQOS_ENDPOINTS_LUNARLANDER[SwqosRegion::Frankfurt as usize]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glaive_defaults_to_quic_endpoint() {
|
||||
assert!(SwqosType::values().contains(&SwqosType::Glaive));
|
||||
let endpoint = SwqosConfig::get_endpoint_with_transport(
|
||||
SwqosType::Glaive,
|
||||
SwqosRegion::Frankfurt,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(endpoint, SWQOS_ENDPOINTS_GLAIVE_QUIC[SwqosRegion::Frankfurt as usize]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glaive_http_transport_uses_binary_http_origin() {
|
||||
let endpoint = SwqosConfig::get_endpoint_with_transport(
|
||||
SwqosType::Glaive,
|
||||
SwqosRegion::Frankfurt,
|
||||
None,
|
||||
Some(SwqosTransport::Http),
|
||||
false,
|
||||
);
|
||||
assert_eq!(endpoint, SWQOS_ENDPOINTS_GLAIVE[SwqosRegion::Frankfurt as usize]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glaive_rejects_unsupported_grpc_transport_without_connecting() {
|
||||
let result = SwqosConfig::get_swqos_client(
|
||||
"http://127.0.0.1:8899".to_string(),
|
||||
CommitmentConfig::processed(),
|
||||
SwqosConfig::Glaive(
|
||||
"00112233-4455-4677-8899-aabbccddeeff".to_string(),
|
||||
SwqosRegion::Frankfurt,
|
||||
None,
|
||||
Some(SwqosTransport::Grpc),
|
||||
),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let error = result.err().expect("Glaive gRPC config must fail");
|
||||
assert!(error.to_string().contains("does not support the gRPC transport"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -32,8 +28,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 +73,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 +98,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 +127,21 @@ impl PumpSwapParams {
|
||||
self
|
||||
}
|
||||
|
||||
/// Quote reserves used by PumpSwap pricing and fee-tier selection.
|
||||
pub fn effective_quote_reserves(&self) -> Result<u64, anyhow::Error> {
|
||||
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 +176,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 +194,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 +219,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 +241,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,
|
||||
@@ -244,17 +263,9 @@ impl PumpSwapParams {
|
||||
rpc: &SolanaRpcClient,
|
||||
mint: &Pubkey,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
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(
|
||||
@@ -275,9 +286,23 @@ impl PumpSwapParams {
|
||||
pool_address: &Pubkey,
|
||||
pool_data: &crate::instruction::utils::pumpswap_types::Pool,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
let (pool_base_token_reserves, pool_quote_token_reserves) =
|
||||
crate::instruction::utils::pumpswap::get_token_balances(pool_data, rpc).await?;
|
||||
let base_mint_supply = fetch_mint_supply(rpc, &pool_data.base_mint).await.ok();
|
||||
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,
|
||||
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 = 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(),
|
||||
@@ -285,7 +310,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
|
||||
@@ -296,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,
|
||||
@@ -319,19 +334,12 @@ 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 {
|
||||
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,
|
||||
@@ -345,13 +353,3 @@ impl PumpSwapParams {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_mint_supply(data: &[u8]) -> Option<u64> {
|
||||
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<u64, anyhow::Error> {
|
||||
let account = rpc.get_account(mint).await?;
|
||||
decode_mint_supply(&account.data).ok_or_else(|| anyhow::anyhow!("Failed to decode mint supply"))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+348
-52
@@ -7,16 +7,45 @@ use crate::instruction::utils::pumpswap::accounts::{
|
||||
use crate::instruction::utils::pumpswap::PumpSwapFeeBasisPoints;
|
||||
use solana_sdk::pubkey::Pubkey;
|
||||
|
||||
#[inline]
|
||||
fn effective_quote_reserve(
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
) -> Result<u64, String> {
|
||||
crate::instruction::utils::pumpswap_types::effective_quote_reserves(
|
||||
quote_reserve,
|
||||
virtual_quote_reserves,
|
||||
)
|
||||
.filter(|reserve| *reserve != 0)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid effective quote reserves: raw={quote_reserve}, virtual={virtual_quote_reserves}."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Creator-side fee bps: fixed coin-creator fee when a creator vault applies, plus optional
|
||||
/// cashback fee bps for cashback-enabled coins (see Pump AMM / parser event field).
|
||||
#[inline]
|
||||
pub(crate) fn creator_side_fee_basis_points(
|
||||
coin_creator: &Pubkey,
|
||||
cashback_fee_basis_points: u64,
|
||||
) -> u64 {
|
||||
) -> Result<u64, String> {
|
||||
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, String> {
|
||||
u64::try_from(value).map_err(|_| format!("Calculated {name} exceeds u64."))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn checked_fee(amount: u64, basis_points: u64, name: &str) -> Result<u64, String> {
|
||||
checked_u64(compute_fee(amount as u128, basis_points as u128), name)
|
||||
}
|
||||
|
||||
/// Result for buying base tokens with base amount input
|
||||
@@ -69,7 +98,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 +110,7 @@ pub fn buy_base_input_internal(
|
||||
slippage_basis_points: u64,
|
||||
base_reserve: u64,
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
coin_creator: &Pubkey,
|
||||
cashback_fee_basis_points: u64,
|
||||
) -> Result<BuyBaseInputResult, String> {
|
||||
@@ -88,10 +119,11 @@ 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,
|
||||
creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points),
|
||||
creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points)?,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -101,36 +133,42 @@ pub fn buy_base_input_internal_with_fees(
|
||||
slippage_basis_points: u64,
|
||||
base_reserve: u64,
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
fee_basis_points: &PumpSwapFeeBasisPoints,
|
||||
) -> Result<BuyBaseInputResult, String> {
|
||||
if base_reserve == 0 || quote_reserve == 0 {
|
||||
return Err("Invalid input: 'baseReserve' or 'quoteReserve' cannot be zero.".to_string());
|
||||
}
|
||||
let effective_quote_reserve = effective_quote_reserve(quote_reserve, virtual_quote_reserves)?;
|
||||
if base > base_reserve {
|
||||
return Err("Cannot buy more base tokens than the pool reserves.".to_string());
|
||||
}
|
||||
|
||||
// Calculate required quote amount using constant product formula
|
||||
let numerator = (quote_reserve as u128) * (base as u128);
|
||||
let numerator = (effective_quote_reserve as u128) * (base as u128);
|
||||
let denominator = base_reserve - base;
|
||||
|
||||
if denominator == 0 {
|
||||
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);
|
||||
@@ -148,7 +186,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 +198,7 @@ pub fn buy_quote_input_internal(
|
||||
slippage_basis_points: u64,
|
||||
base_reserve: u64,
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
coin_creator: &Pubkey,
|
||||
cashback_fee_basis_points: u64,
|
||||
) -> Result<BuyQuoteInputResult, String> {
|
||||
@@ -167,10 +207,11 @@ 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,
|
||||
creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points),
|
||||
creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points)?,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -180,18 +221,23 @@ pub fn buy_quote_input_internal_with_fees(
|
||||
slippage_basis_points: u64,
|
||||
base_reserve: u64,
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
fee_basis_points: &PumpSwapFeeBasisPoints,
|
||||
) -> Result<BuyQuoteInputResult, String> {
|
||||
if base_reserve == 0 || quote_reserve == 0 {
|
||||
return Err("Invalid input: 'baseReserve' or 'quoteReserve' cannot be zero.".to_string());
|
||||
}
|
||||
let effective_quote_reserve = effective_quote_reserve(quote_reserve, virtual_quote_reserves)?;
|
||||
|
||||
// Calculate total fee basis points
|
||||
let total_fee_bps = fee_basis_points
|
||||
.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;
|
||||
@@ -200,28 +246,36 @@ 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;
|
||||
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());
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -232,7 +286,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 +298,7 @@ pub fn sell_base_input_internal(
|
||||
slippage_basis_points: u64,
|
||||
base_reserve: u64,
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
coin_creator: &Pubkey,
|
||||
cashback_fee_basis_points: u64,
|
||||
) -> Result<SellBaseInputResult, String> {
|
||||
@@ -251,10 +307,11 @@ 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,
|
||||
creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points),
|
||||
creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points)?,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -264,32 +321,43 @@ pub fn sell_base_input_internal_with_fees(
|
||||
slippage_basis_points: u64,
|
||||
base_reserve: u64,
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
fee_basis_points: &PumpSwapFeeBasisPoints,
|
||||
) -> Result<SellBaseInputResult, String> {
|
||||
if base_reserve == 0 || quote_reserve == 0 {
|
||||
return Err("Invalid input: 'baseReserve' or 'quoteReserve' cannot be zero.".to_string());
|
||||
}
|
||||
let effective_quote_reserve = effective_quote_reserve(quote_reserve, virtual_quote_reserves)?;
|
||||
|
||||
// Calculate quote amount out using constant product formula
|
||||
let quote_amount_out = ((quote_reserve as u128) * (base as u128)
|
||||
/ ((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());
|
||||
}
|
||||
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 +378,22 @@ fn calculate_quote_amount_out(
|
||||
lp_fee_basis_points: u64,
|
||||
protocol_fee_basis_points: u64,
|
||||
coin_creator_fee_basis_points: u64,
|
||||
) -> u64 {
|
||||
let total_fee_basis_points =
|
||||
lp_fee_basis_points + protocol_fee_basis_points + coin_creator_fee_basis_points;
|
||||
let denominator = MAX_FEE_BASIS_POINTS - total_fee_basis_points;
|
||||
ceil_div((user_quote_amount_out as u128) * (MAX_FEE_BASIS_POINTS as u128), denominator as u128)
|
||||
as u64
|
||||
) -> Result<u64, String> {
|
||||
let total_fee_basis_points = lp_fee_basis_points
|
||||
.checked_add(protocol_fee_basis_points)
|
||||
.and_then(|fees| fees.checked_add(coin_creator_fee_basis_points))
|
||||
.ok_or_else(|| "Fee basis points overflow.".to_string())?;
|
||||
let denominator = MAX_FEE_BASIS_POINTS
|
||||
.checked_sub(total_fee_basis_points)
|
||||
.ok_or_else(|| "Total fee basis points must be less than 10,000.".to_string())?;
|
||||
if denominator == 0 {
|
||||
return Err("Total fee basis points must be less than 10,000.".to_string());
|
||||
}
|
||||
let raw_quote = ceil_div(
|
||||
(user_quote_amount_out as u128) * (MAX_FEE_BASIS_POINTS as u128),
|
||||
denominator as u128,
|
||||
);
|
||||
u64::try_from(raw_quote).map_err(|_| "Calculated quote amount exceeds u64.".to_string())
|
||||
}
|
||||
|
||||
/// Calculate base tokens needed to receive a specific amount of quote tokens
|
||||
@@ -324,7 +402,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 +414,7 @@ pub fn sell_quote_input_internal(
|
||||
slippage_basis_points: u64,
|
||||
base_reserve: u64,
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
coin_creator: &Pubkey,
|
||||
cashback_fee_basis_points: u64,
|
||||
) -> Result<SellQuoteInputResult, String> {
|
||||
@@ -343,10 +423,11 @@ 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,
|
||||
creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points),
|
||||
creator_side_fee_basis_points(coin_creator, cashback_fee_basis_points)?,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -356,6 +437,7 @@ pub fn sell_quote_input_internal_with_fees(
|
||||
slippage_basis_points: u64,
|
||||
base_reserve: u64,
|
||||
quote_reserve: u64,
|
||||
virtual_quote_reserves: i128,
|
||||
fee_basis_points: &PumpSwapFeeBasisPoints,
|
||||
) -> Result<SellQuoteInputResult, String> {
|
||||
if base_reserve == 0 || quote_reserve == 0 {
|
||||
@@ -364,6 +446,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 +454,232 @@ 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 = 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());
|
||||
}
|
||||
|
||||
// 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 = 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);
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
#[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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user