feat: harden trading SDK examples
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
use sol_trade_sdk::common::fast_fn::get_associated_token_address_with_program_id_fast_use_seed;
|
||||
use sol_trade_sdk::common::TradeConfig;
|
||||
use sol_trade_sdk::common::{clock::now_micros, SolanaRpcClient, TradeConfig};
|
||||
use sol_trade_sdk::TradeTokenType;
|
||||
use sol_trade_sdk::{
|
||||
common::AnyResult,
|
||||
@@ -8,11 +7,10 @@ use sol_trade_sdk::{
|
||||
core::params::{DexParamEnum, PumpSwapParams},
|
||||
factory::DexType,
|
||||
},
|
||||
SolanaTrade,
|
||||
AccountPolicy, BuyAmount, SellAmount, SimpleBuyParams, SimpleSellParams, SolanaTrade,
|
||||
};
|
||||
use solana_commitment_config::CommitmentConfig;
|
||||
use solana_sdk::signature::Keypair;
|
||||
use solana_sdk::{pubkey::Pubkey, signer::Signer};
|
||||
use solana_sdk::{hash::Hash, pubkey::Pubkey};
|
||||
use solana_streamer_sdk::streaming::event_parser::{
|
||||
common::filter::EventTypeFilter, protocols::pumpswap::PumpSwapBuyEvent,
|
||||
};
|
||||
@@ -25,24 +23,132 @@ 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,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::watch;
|
||||
|
||||
// Global static flag to ensure transaction is executed only once
|
||||
static ALREADY_EXECUTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const BLOCKHASH_REFRESH_INTERVAL: Duration = Duration::from_millis(400);
|
||||
const MAX_BLOCKHASH_AGE: Duration = Duration::from_secs(20);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct EventSelection {
|
||||
target_mint: Option<Pubkey>,
|
||||
target_pool: Option<Pubkey>,
|
||||
max_event_age_ms: u64,
|
||||
}
|
||||
|
||||
impl EventSelection {
|
||||
fn from_env() -> AnyResult<Self> {
|
||||
let target_mint = parse_optional_pubkey("TARGET_MINT")?;
|
||||
let target_pool = parse_optional_pubkey("TARGET_POOL")?;
|
||||
if target_mint.is_none() && target_pool.is_none() {
|
||||
anyhow::bail!("set TARGET_MINT or TARGET_POOL before running this live example");
|
||||
}
|
||||
let max_event_age_ms = std::env::var("MAX_EVENT_AGE_MS")
|
||||
.unwrap_or_else(|_| "1000".to_string())
|
||||
.parse::<u64>()
|
||||
.map_err(|_| anyhow::anyhow!("MAX_EVENT_AGE_MS must be a positive integer"))?;
|
||||
if max_event_age_ms == 0 || max_event_age_ms > i64::MAX as u64 / 1_000 {
|
||||
anyhow::bail!("MAX_EVENT_AGE_MS is outside the supported range");
|
||||
}
|
||||
Ok(Self { target_mint, target_pool, max_event_age_ms })
|
||||
}
|
||||
|
||||
fn matches(self, pool: Pubkey, base_mint: Pubkey, quote_mint: Pubkey, recv_us: i64) -> bool {
|
||||
if self.target_pool.is_some_and(|target| target != pool) {
|
||||
return false;
|
||||
}
|
||||
if self.target_mint.is_some_and(|target| target != base_mint && target != quote_mint) {
|
||||
return false;
|
||||
}
|
||||
is_event_fresh(recv_us, now_micros(), self.max_event_age_ms)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_pubkey(key: &str) -> AnyResult<Option<Pubkey>> {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| Pubkey::from_str(&value).map_err(anyhow::Error::from))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn is_event_fresh(recv_us: i64, now_us: i64, max_age_ms: u64) -> bool {
|
||||
recv_us > 0
|
||||
&& now_us >= recv_us
|
||||
&& now_us.saturating_sub(recv_us) <= (max_age_ms as i64).saturating_mul(1_000)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedBlockhash {
|
||||
hash: Hash,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BlockhashCache {
|
||||
receiver: watch::Receiver<CachedBlockhash>,
|
||||
}
|
||||
|
||||
impl BlockhashCache {
|
||||
async fn start(rpc: Arc<SolanaRpcClient>) -> AnyResult<Self> {
|
||||
let initial =
|
||||
CachedBlockhash { hash: rpc.get_latest_blockhash().await?, fetched_at: Instant::now() };
|
||||
let (sender, receiver) = watch::channel(initial);
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(BLOCKHASH_REFRESH_INTERVAL);
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match rpc.get_latest_blockhash().await {
|
||||
Ok(hash) => {
|
||||
if sender
|
||||
.send(CachedBlockhash { hash, fetched_at: Instant::now() })
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!("warning: blockhash refresh failed: {err}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self { receiver })
|
||||
}
|
||||
|
||||
fn latest(&self) -> AnyResult<Hash> {
|
||||
let cached = self.receiver.borrow().clone();
|
||||
if cached.fetched_at.elapsed() > MAX_BLOCKHASH_AGE {
|
||||
anyhow::bail!("cached blockhash is older than {} seconds", MAX_BLOCKHASH_AGE.as_secs());
|
||||
}
|
||||
Ok(cached.hash)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Subscribing to GRPC events...");
|
||||
|
||||
let selection = EventSelection::from_env()?;
|
||||
|
||||
let trade_client = Arc::new(create_solana_trade_client().await?);
|
||||
let blockhash_cache = BlockhashCache::start(trade_client.infrastructure.rpc.clone()).await?;
|
||||
|
||||
let grpc = YellowstoneGrpc::new(
|
||||
"https://solana-yellowstone-grpc.publicnode.com:443".to_string(),
|
||||
None,
|
||||
std::env::var("GRPC_ENDPOINT")
|
||||
.unwrap_or_else(|_| "https://solana-yellowstone-grpc.publicnode.com:443".to_string()),
|
||||
std::env::var("GRPC_AUTH_TOKEN").ok(),
|
||||
)?;
|
||||
|
||||
let callback = create_event_callback();
|
||||
let callback = create_event_callback(trade_client, blockhash_cache, selection);
|
||||
let protocols = vec![Protocol::PumpSwap];
|
||||
// Filter accounts
|
||||
let account_include = vec![
|
||||
@@ -83,8 +189,12 @@ 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>| {
|
||||
fn create_event_callback(
|
||||
client: Arc<SolanaTrade>,
|
||||
blockhash_cache: BlockhashCache,
|
||||
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;
|
||||
@@ -92,13 +202,22 @@ fn create_event_callback() -> impl Fn(Box<dyn UnifiedEvent>) {
|
||||
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(event_clone).await {
|
||||
if let Err(err) = pumpswap_trade_with_grpc_buy_event(
|
||||
client,
|
||||
blockhash_cache,
|
||||
event_clone,
|
||||
).await {
|
||||
eprintln!("Error in trade: {:?}", err);
|
||||
std::process::exit(0);
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -109,13 +228,22 @@ fn create_event_callback() -> impl Fn(Box<dyn UnifiedEvent>) {
|
||||
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(event_clone).await {
|
||||
if let Err(err) = pumpswap_trade_with_grpc_sell_event(
|
||||
client,
|
||||
blockhash_cache,
|
||||
event_clone,
|
||||
).await {
|
||||
eprintln!("Error in trade: {:?}", err);
|
||||
std::process::exit(0);
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -127,9 +255,10 @@ fn create_event_callback() -> impl Fn(Box<dyn UnifiedEvent>) {
|
||||
/// Create SolanaTrade client
|
||||
/// Initializes a new SolanaTrade client with configuration
|
||||
async fn create_solana_trade_client() -> AnyResult<SolanaTrade> {
|
||||
println!("🚀 Initializing SolanaTrade client...");
|
||||
let payer = Keypair::from_base58_string("your_payer_keypair_here");
|
||||
let rpc_url = "https://api.mainnet-beta.solana.com".to_string();
|
||||
println!("Initializing SolanaTrade client...");
|
||||
let payer = sol_trade_sdk::common::keypair::load_keypair_from_env("PRIVATE_KEY")?;
|
||||
let rpc_url = std::env::var("RPC_URL")
|
||||
.unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".to_string());
|
||||
let commitment = CommitmentConfig::confirmed();
|
||||
let swqos_configs: Vec<SwqosConfig> = vec![SwqosConfig::Default(rpc_url.clone())];
|
||||
let trade_config = TradeConfig::builder(rpc_url, swqos_configs, commitment)
|
||||
@@ -141,12 +270,15 @@ async fn create_solana_trade_client() -> AnyResult<SolanaTrade> {
|
||||
// .mev_protection(false) // default: false
|
||||
.build();
|
||||
let solana_trade = SolanaTrade::new(Arc::new(payer), trade_config).await;
|
||||
println!("✅ SolanaTrade client initialized successfully!");
|
||||
println!("SolanaTrade client initialized successfully");
|
||||
Ok(solana_trade)
|
||||
}
|
||||
|
||||
async fn pumpswap_trade_with_grpc_buy_event(trade_info: PumpSwapBuyEvent) -> AnyResult<()> {
|
||||
let client = create_solana_trade_client().await?;
|
||||
async fn pumpswap_trade_with_grpc_buy_event(
|
||||
client: Arc<SolanaTrade>,
|
||||
blockhash_cache: BlockhashCache,
|
||||
trade_info: PumpSwapBuyEvent,
|
||||
) -> AnyResult<()> {
|
||||
let params = PumpSwapParams::from_trade_with_fee_basis_points(
|
||||
trade_info.pool,
|
||||
trade_info.base_mint,
|
||||
@@ -175,12 +307,16 @@ async fn pumpswap_trade_with_grpc_buy_event(trade_info: PumpSwapBuyEvent) -> Any
|
||||
} else {
|
||||
trade_info.base_mint
|
||||
};
|
||||
pumpswap_trade_with_grpc(&client, mint, params).await?;
|
||||
pumpswap_trade_with_grpc(&client, &blockhash_cache, trade_info.metadata.recv_us, mint, params)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pumpswap_trade_with_grpc_sell_event(trade_info: PumpSwapSellEvent) -> AnyResult<()> {
|
||||
let client = create_solana_trade_client().await?;
|
||||
async fn pumpswap_trade_with_grpc_sell_event(
|
||||
client: Arc<SolanaTrade>,
|
||||
blockhash_cache: BlockhashCache,
|
||||
trade_info: PumpSwapSellEvent,
|
||||
) -> AnyResult<()> {
|
||||
let params = PumpSwapParams::from_trade_with_fee_basis_points(
|
||||
trade_info.pool,
|
||||
trade_info.base_mint,
|
||||
@@ -209,92 +345,106 @@ async fn pumpswap_trade_with_grpc_sell_event(trade_info: PumpSwapSellEvent) -> A
|
||||
} else {
|
||||
trade_info.base_mint
|
||||
};
|
||||
pumpswap_trade_with_grpc(&client, mint, params).await?;
|
||||
pumpswap_trade_with_grpc(&client, &blockhash_cache, trade_info.metadata.recv_us, mint, params)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pumpswap_trade_with_grpc(
|
||||
client: &SolanaTrade,
|
||||
blockhash_cache: &BlockhashCache,
|
||||
grpc_recv_us: i64,
|
||||
mint_pubkey: Pubkey,
|
||||
params: PumpSwapParams,
|
||||
) -> AnyResult<()> {
|
||||
println!("Testing PumpSwap trading...");
|
||||
let slippage_basis_points = Some(500);
|
||||
let recent_blockhash = client.infrastructure.rpc.get_latest_blockhash().await?;
|
||||
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;
|
||||
let program_id = if params.base_mint == mint_pubkey {
|
||||
params.base_token_program
|
||||
} else if params.quote_mint == mint_pubkey {
|
||||
params.quote_token_program
|
||||
} 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?;
|
||||
|
||||
// Buy tokens
|
||||
println!("Buying tokens from PumpSwap...");
|
||||
let buy_token_amount = 300_000;
|
||||
let buy_params = sol_trade_sdk::TradeBuyParams {
|
||||
dex_type: DexType::PumpSwap,
|
||||
input_token_type: if is_sol { TradeTokenType::SOL } else { TradeTokenType::USDC },
|
||||
mint: mint_pubkey,
|
||||
input_token_amount: buy_token_amount,
|
||||
slippage_basis_points: slippage_basis_points,
|
||||
recent_blockhash: Some(recent_blockhash),
|
||||
extension_params: DexParamEnum::PumpSwap(params.clone()),
|
||||
address_lookup_table_accounts: Vec::new(),
|
||||
wait_tx_confirmed: true,
|
||||
wait_for_all_submits: false,
|
||||
create_input_token_ata: is_sol,
|
||||
close_input_token_ata: is_sol,
|
||||
create_mint_ata: true,
|
||||
durable_nonce: None,
|
||||
fixed_output_token_amount: None,
|
||||
gas_fee_strategy: gas_fee_strategy.clone(),
|
||||
simulate: false,
|
||||
use_exact_sol_amount: None,
|
||||
grpc_recv_us: None,
|
||||
};
|
||||
client.buy(buy_params).await?;
|
||||
let buy_params = SimpleBuyParams::new(
|
||||
DexType::PumpSwap,
|
||||
if is_sol { TradeTokenType::SOL } else { TradeTokenType::USDC },
|
||||
mint_pubkey,
|
||||
BuyAmount::WithMaxInput { quote_amount: buy_token_amount },
|
||||
DexParamEnum::PumpSwap(params.clone()),
|
||||
recent_blockhash,
|
||||
gas_fee_strategy.clone(),
|
||||
)
|
||||
.slippage_basis_points(slippage_basis_points.unwrap_or(500))
|
||||
.account_policy(AccountPolicy::Auto)
|
||||
.wait_tx_confirmed(true)
|
||||
.grpc_recv_us(grpc_recv_us);
|
||||
let (ok, sigs, err, _) = client.buy_simple(buy_params).await?;
|
||||
if !ok {
|
||||
anyhow::bail!("buy failed: {:?}; signatures: {:?}", err, sigs);
|
||||
}
|
||||
|
||||
// Sell tokens
|
||||
println!("Selling tokens from PumpSwap...");
|
||||
|
||||
let rpc = client.infrastructure.rpc.clone();
|
||||
let payer = client.payer.pubkey();
|
||||
let program_id = if params.base_mint == mint_pubkey {
|
||||
params.base_token_program
|
||||
} else {
|
||||
params.quote_token_program
|
||||
};
|
||||
let account = get_associated_token_address_with_program_id_fast_use_seed(
|
||||
&payer,
|
||||
&mint_pubkey,
|
||||
&program_id,
|
||||
client.use_seed_optimize,
|
||||
);
|
||||
let balance = rpc.get_token_account_balance(&account).await?;
|
||||
let amount_token = balance.amount.parse::<u64>().unwrap();
|
||||
let sell_params = sol_trade_sdk::TradeSellParams {
|
||||
dex_type: DexType::PumpSwap,
|
||||
output_token_type: if is_sol { TradeTokenType::SOL } else { TradeTokenType::USDC },
|
||||
mint: mint_pubkey,
|
||||
input_token_amount: amount_token,
|
||||
slippage_basis_points: slippage_basis_points,
|
||||
recent_blockhash: Some(recent_blockhash),
|
||||
with_tip: false,
|
||||
extension_params: DexParamEnum::PumpSwap(params.clone()),
|
||||
address_lookup_table_accounts: Vec::new(),
|
||||
wait_tx_confirmed: true,
|
||||
wait_for_all_submits: false,
|
||||
create_output_token_ata: is_sol,
|
||||
close_output_token_ata: is_sol,
|
||||
close_mint_token_ata: false,
|
||||
durable_nonce: None,
|
||||
fixed_output_token_amount: None,
|
||||
gas_fee_strategy: gas_fee_strategy,
|
||||
simulate: false,
|
||||
grpc_recv_us: None,
|
||||
};
|
||||
client.sell(sell_params).await?;
|
||||
let balance_after =
|
||||
client.get_payer_token_balance_with_program(&mint_pubkey, &program_id).await?;
|
||||
let position_amount = balance_after.checked_sub(balance_before).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"token balance decreased from {} to {}; refusing to sell existing holdings",
|
||||
balance_before,
|
||||
balance_after
|
||||
)
|
||||
})?;
|
||||
if position_amount == 0 {
|
||||
anyhow::bail!("confirmed buy did not increase token balance; refusing to sell");
|
||||
}
|
||||
let sell_params_from_rpc =
|
||||
PumpSwapParams::from_pool_address_by_rpc(&client.infrastructure.rpc, ¶ms.pool).await?;
|
||||
let sell_params = SimpleSellParams::new(
|
||||
DexType::PumpSwap,
|
||||
if is_sol { TradeTokenType::SOL } else { TradeTokenType::USDC },
|
||||
mint_pubkey,
|
||||
SellAmount::ExactInput(position_amount),
|
||||
DexParamEnum::PumpSwap(sell_params_from_rpc),
|
||||
blockhash_cache.latest()?,
|
||||
gas_fee_strategy,
|
||||
)
|
||||
.slippage_basis_points(slippage_basis_points.unwrap_or(500))
|
||||
.account_policy(AccountPolicy::Auto)
|
||||
.wait_tx_confirmed(true)
|
||||
.with_tip(false);
|
||||
let (ok, sigs, err, _) = client.sell_simple(sell_params).await?;
|
||||
if !ok {
|
||||
anyhow::bail!("sell failed: {:?}; signatures: {:?}", err, sigs);
|
||||
}
|
||||
|
||||
// Exit program
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_event_fresh;
|
||||
|
||||
#[test]
|
||||
fn event_freshness_has_a_strict_boundary() {
|
||||
assert!(!is_event_fresh(0, 1_000_000, 100));
|
||||
assert!(!is_event_fresh(1_000_001, 1_000_000, 100));
|
||||
assert!(!is_event_fresh(899_999, 1_000_000, 100));
|
||||
assert!(is_event_fresh(900_000, 1_000_000, 100));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user