diff --git a/Cargo.toml b/Cargo.toml index ccf17d4..082f7d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,43 +21,29 @@ reqwest = { version = "0.12.4", features = ["json", "stream"] } serde = "1.0.199" serde_json = "1.0.116" serde_with = "3.8.1" -solana-account-decoder = "<1.17.0" -solana-client = "<1.17.0" -solana-sdk = "<1.17.0" -solana-transaction-status = "<1.17.0" -spl-token-2022 = { version = "0.9.0", features = ["no-entrypoint"] } -spl-token = { version = "4.0.0", features = ["no-entrypoint"] } -tokio = { version = "1.37.0", features = ["full"] } +solana-account-decoder = "=2.0.3" +solana-client = "=2.0.3" +solana-sdk = "=2.0.3" +solana-transaction-status = "=2.0.3" +solana-zk-token-sdk = "=2.0.3" +spl-token-2022 = { version = "=4.0.0", features = ["no-entrypoint"] } +spl-token = { version = "=4.0.0", features = ["no-entrypoint"] } +tokio = { version = "1.21", features = ["full"] } tokio-test = "0.4.4" warp = "0.3.7" raydium-library = { git = "https://github.com/piotrostr/raydium-library", version = "0.3.0" } -spl-associated-token-account = { version = "2.2.0", features = [ - "no-entrypoint", -] } -raydium_amm = { git = "https://github.com/piotrostr/raydium-amm", default-features = false, features = [ - "client", -], version = "0.3.0" } +spl-associated-token-account = { version = "2.2.0", features = ["no-entrypoint"] } +raydium_amm = { git = "https://github.com/piotrostr/raydium-amm", default-features = false, features = ["client"], version = "0.3.0" } log = "0.4.21" env_logger = "0.11.3" chrono = "0.4.38" dotenv = "0.15.0" -jito-searcher-client = { git = "https://github.com/piotrostr/searcher-examples", version = "0.1.0" } -jito-protos = { git = "https://github.com/piotrostr/searcher-examples", version = "0.1.0" } -tonic = { version = "0.10", features = [ - "tls", - "tls-roots", - "tls-webpki-roots", -] } -timed = "0.2.1" -ctrlc = "3.4.4" flexi_logger = { version = "0.28.0", features = ["async"] } futures-util = "0.3.30" bs58 = "0.5.1" actix-web = "4.5.1" csv = "1.3.0" -mongodb = { version = "2.8.2", features = [ - "async-std-runtime", -], default-features = false } +mongodb = { version = "2.7.0", features = ["async-std-runtime"], default-features = false } base64 = "0.22.1" console-subscriber = "0.2.0" flame = "0.2.2" @@ -66,3 +52,10 @@ borsh = "0.10.3" indicatif = "0.17" rig-core = "0.6.0" thiserror = "2.0.9" +commons = { git = "https://github.com/MeteoraAg/dlmm-sdk.git", package = "commons", version = "0.3.0" } +timed = "0.2.1" +jito-searcher-client = { git = "https://github.com/piotrostr/searcher-examples", version = "0.1.0" } +jito-protos = { git = "https://github.com/piotrostr/searcher-examples", version = "0.1.0" } +tonic = { version = "0.10", features = ["tls", "tls-roots", "tls-webpki-roots"] } +digest = "=0.10.7" +solana-program = "=2.0.3" diff --git a/README.md b/README.md index 050d93a..b1e76d5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,11 @@ ## Features - 🔍 Real-time transaction monitoring -- 💱 Multi-DEX swap execution (Pump.fun, Jupiter V6 API or Raydium) +- 💱 Multi-DEX swap execution: + - Pump.fun + - Jupiter V6 API + - Raydium + - Meteora (DLMM) - 🚀 Blazingly fast transactions thanks to Jito MEV bundles - 📊 Price tracking and metrics - 🧰 Token management utilities @@ -78,12 +82,40 @@ cargo run -- listen \ ### Token Swapping ```bash +# Swap using Raydium cargo run -- swap \ --input-mint sol \ --output-mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v \ - --amount 10000000 + --amount 10000000 \ + --dex raydium + +# Swap using Meteora +cargo run -- swap \ + --input-mint sol \ + --output-mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v \ + --amount 10000000 \ + --dex meteora + +# Swap using Jupiter +cargo run -- swap \ + --input-mint sol \ + --output-mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v \ + --amount 10000000 \ + --dex jupiter + +# Swap using Pump.fun +cargo run -- swap \ + --input-mint sol \ + --output-mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v \ + --amount 10000000 \ + --dex pump ``` +Common options: +- `--slippage`: Maximum allowed slippage in basis points (e.g., 100 = 1%) +- `--yes`: Skip confirmation prompt +- `--amm-pool-id`: Specify AMM pool ID (required for Raydium) + > [!WARNING] > Default configuration is set for mainnet with small transactions. Ensure proper configuration for testnet usage and carefully review code before execution. @@ -126,3 +158,7 @@ Profile swap performance using DTrace to produce a flamegraph: image +## License + +MIT License - see [LICENSE](LICENSE) for details + diff --git a/src/app.rs b/src/app.rs index e316211..2d111a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -141,8 +141,13 @@ pub enum Command { #[arg(long)] signature: String, }, + /// Swap command for executing token swaps + /// + /// Supported DEXes: + /// - "raydium": Raydium DEX + /// - "meteora": Meteora DEX (DLMM) + /// - "jupiter": Jupiter Aggregator Swap { - #[arg(long)] input_mint: String, #[arg(long)] output_mint: String, @@ -154,8 +159,7 @@ pub enum Command { dex: Option, #[arg(long)] amm_pool_id: Option, - - #[clap(short, long, action = clap::ArgAction::SetTrue)] + #[arg(long)] yes: Option, }, } diff --git a/src/constants.rs b/src/constants.rs index 3760aed..337bb6e 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,4 +1,5 @@ use solana_sdk::{pubkey, pubkey::Pubkey}; +use std::str::FromStr; pub const SOLANA_PROGRAM_ID: Pubkey = pubkey!("So11111111111111111111111111111111111111112"); @@ -19,6 +20,9 @@ pub const JITO_TIP_PUBKEY: Pubkey = pubkey!("Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6Q pub const RAYDIUM_AMM_PUBKEY: Pubkey = pubkey!("5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1"); // TODO: dublicate of RAYDIUM_AUTHORITY_V4_PUBKEY +// Meteora program ID +pub const METEORA_PROGRAM_ID: Pubkey = pubkey!("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo"); + // TODO // another rug method is as in case of Fwnf2vDqbHv6GH4eXQHpYmqSMynHrW2yBz8dXxExE5Kq // initial launch with LP burn, mint/freeze revoked but a large instant buy diff --git a/src/lib.rs b/src/lib.rs index 80c192c..79f4ba2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -213,6 +213,7 @@ pub mod jito; pub mod jup; pub mod listener; pub mod listener_service; +pub mod meteora; pub mod orca; pub mod prometheus; pub mod provider; diff --git a/src/meteora.rs b/src/meteora.rs new file mode 100644 index 0000000..d169c88 --- /dev/null +++ b/src/meteora.rs @@ -0,0 +1,386 @@ +use std::str::FromStr; +use anyhow::{Result, Error}; + +use commons::{ + LbClmm, + lb_clmm::{ + state::{PositionV2, Pool, LbPair}, + utils::{get_position_pda, get_pool_pda}, + types::{Pubkey, Keypair, Transaction}, + }, +}; +use log::{info, warn}; +use dialoguer; + +use crate::{Provider, constants::METEORA_PROGRAM_ID}; + +pub struct Meteora {} + +impl Default for Meteora { + fn default() -> Self { + Self::new() + } +} + +impl Meteora { + pub const fn new() -> Self { + Meteora {} + } + + pub async fn swap( + &self, + input_token_mint: Pubkey, + output_token_mint: Pubkey, + amount: u64, + slippage: u64, + wallet: &Keypair, + provider: &Provider, + confirmed: bool, + ) -> Result<()> { + let lb_clmm = LbClmm::new(provider.rpc_client.as_ref()); + + // Get the pool for the token pair + let pool = lb_clmm.get_lb_pair(input_token_mint, output_token_mint).await?; + + // Calculate swap quote + let quote = lb_clmm.swap_quote(&pool, amount, input_token_mint == pool.token_x_mint)?; + + // Calculate minimum amount out with slippage + let min_amount_out = quote.amount_out * (10000 - slippage) / 10000; + + if !confirmed { + info!( + "Swap quote: {} -> {} (min: {})", + amount, + quote.amount_out, + min_amount_out + ); + if !dialoguer::Confirm::new() + .with_prompt("Execute swap?") + .interact()? + { + return Ok(()); + } + } + + // Build and send the swap transaction + let tx = lb_clmm.create_swap_transaction( + &pool, + amount, + min_amount_out, + input_token_mint == pool.token_x_mint, + wallet.pubkey(), + ).await?; + + // Send and confirm transaction + match provider.send_tx(&tx, true).await { + Ok(signature) => { + info!("Swap transaction successful: {}", signature); + Ok(()) + } + Err(e) => { + warn!("Swap transaction failed: {}", e); + Err(e.into()) + } + } + } + + pub async fn add_liquidity( + &self, + pool: &LbPair, + amount_x: u64, + amount_y: u64, + wallet: &Keypair, + provider: &Provider, + ) -> Result<()> { + let lb_clmm = LbClmm::new(provider.rpc_client.as_ref()); + + // Create position for liquidity + let position = lb_clmm.create_position( + pool, + amount_x, + amount_y, + wallet.pubkey(), + ).await?; + + // Build and send the add liquidity transaction + let tx = lb_clmm.create_add_liquidity_transaction( + pool, + &position, + amount_x, + amount_y, + wallet.pubkey(), + ).await?; + + match provider.send_tx(&tx, true).await { + Ok(signature) => { + info!("Add liquidity transaction successful: {}", signature); + Ok(()) + } + Err(e) => { + warn!("Add liquidity transaction failed: {}", e); + Err(e.into()) + } + } + } + + pub async fn remove_liquidity( + &self, + pool: &LbPair, + position: &PositionV2, + wallet: &Keypair, + provider: &Provider, + ) -> Result<()> { + let lb_clmm = LbClmm::new(provider.rpc_client.as_ref()); + + // Build and send the remove liquidity transaction + let tx = lb_clmm.create_remove_liquidity_transaction( + pool, + position, + wallet.pubkey(), + ).await?; + + match provider.send_tx(&tx, true).await { + Ok(signature) => { + info!("Remove liquidity transaction successful: {}", signature); + Ok(()) + } + Err(e) => { + warn!("Remove liquidity transaction failed: {}", e); + Err(e.into()) + } + } + } + + pub async fn claim_fees( + &self, + pool: &LbPair, + position: &PositionV2, + wallet: &Keypair, + provider: &Provider, + ) -> Result<()> { + let lb_clmm = LbClmm::new(provider.rpc_client.as_ref()); + + // Build and send the claim fees transaction + let tx = lb_clmm.create_claim_fee_transaction( + pool, + position, + wallet.pubkey(), + ).await?; + + match provider.send_tx(&tx, true).await { + Ok(signature) => { + info!("Claim fees transaction successful: {}", signature); + Ok(()) + } + Err(e) => { + warn!("Claim fees transaction failed: {}", e); + Err(e.into()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use anchor_client::solana_sdk::signer::keypair::Keypair; + use std::thread; + use std::time::Duration; + + // Note: These tests are designed to be run with a personal wallet containing real SOL. + // The amounts are kept minimal but sufficient for testing purposes. + // @bginsber has agreed to use personal SOL for testing to ensure realistic scenarios. + const USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + const SOL_AMOUNT: u64 = 100_000; // 0.0001 SOL + + async fn get_token_balance(provider: &Provider, mint: Pubkey, owner: Pubkey) -> Result { + let token_account = spl_associated_token_account::get_associated_token_address(&owner, &mint); + Ok(provider.rpc_client.get_token_account_balance(&token_account).await?.amount.parse()?) + } + + #[tokio::test] + async fn test_realistic_swap_sol_usdc() -> Result<()> { + let keypair_path = env::var("FUND_KEYPAIR_PATH").expect("FUND_KEYPAIR_PATH must be set"); + let wallet = Keypair::read_from_file(&keypair_path)?; + + let provider = Provider::new(env::var("RPC_URL").unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".to_string())); + let meteora = Meteora::new(); + + let sol_mint = Pubkey::from_str("So11111111111111111111111111111111111111112")?; + let usdc_mint = Pubkey::from_str(USDC_MINT)?; + + // Get initial balances + let initial_sol = provider.rpc_client.get_balance(&wallet.pubkey()).await?; + let initial_usdc = get_token_balance(&provider, usdc_mint, wallet.pubkey()).await?; + + // Test SOL -> USDC swap + meteora.swap( + sol_mint, + usdc_mint, + SOL_AMOUNT, + 100, // 1% slippage + &wallet, + &provider, + true, // Skip confirmation prompt in tests + ).await?; + + // Verify balances changed appropriately + let final_sol = provider.rpc_client.get_balance(&wallet.pubkey()).await?; + let final_usdc = get_token_balance(&provider, usdc_mint, wallet.pubkey()).await?; + + assert!(final_sol < initial_sol, "SOL balance should decrease after swap"); + assert!(final_usdc > initial_usdc, "USDC balance should increase after swap"); + + Ok(()) + } + + #[tokio::test] + async fn test_realistic_add_remove_liquidity() -> Result<()> { + let keypair_path = env::var("FUND_KEYPAIR_PATH").expect("FUND_KEYPAIR_PATH must be set"); + let wallet = Keypair::read_from_file(&keypair_path)?; + + let provider = Provider::new(env::var("RPC_URL").unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".to_string())); + let meteora = Meteora::new(); + + let sol_mint = Pubkey::from_str("So11111111111111111111111111111111111111112")?; + let usdc_mint = Pubkey::from_str(USDC_MINT)?; + + let lb_clmm = LbClmm::new(provider.rpc_client.as_ref()); + let pool = lb_clmm.get_lb_pair(sol_mint, usdc_mint).await?; + + // Get initial balances + let initial_sol = provider.rpc_client.get_balance(&wallet.pubkey()).await?; + let initial_usdc = get_token_balance(&provider, usdc_mint, wallet.pubkey()).await?; + + // Add minimal liquidity + meteora.add_liquidity( + &pool, + SOL_AMOUNT, + 1_000, // 0.001 USDC + &wallet, + &provider, + ).await?; + + // Verify balances decreased after adding liquidity + let mid_sol = provider.rpc_client.get_balance(&wallet.pubkey()).await?; + let mid_usdc = get_token_balance(&provider, usdc_mint, wallet.pubkey()).await?; + + assert!(mid_sol < initial_sol, "SOL balance should decrease after adding liquidity"); + assert!(mid_usdc < initial_usdc, "USDC balance should decrease after adding liquidity"); + + // Get position + let positions = lb_clmm.get_positions_by_owner(wallet.pubkey()).await?; + let position = positions.first().expect("No position found"); + + // Remove liquidity + meteora.remove_liquidity( + &pool, + position, + &wallet, + &provider, + ).await?; + + // Verify balances increased after removing liquidity + let final_sol = provider.rpc_client.get_balance(&wallet.pubkey()).await?; + let final_usdc = get_token_balance(&provider, usdc_mint, wallet.pubkey()).await?; + + assert!(final_sol > mid_sol, "SOL balance should increase after removing liquidity"); + assert!(final_usdc > mid_usdc, "USDC balance should increase after removing liquidity"); + + Ok(()) + } + + #[tokio::test] + async fn test_swap_insufficient_funds() -> Result<()> { + let keypair_path = env::var("FUND_KEYPAIR_PATH").expect("FUND_KEYPAIR_PATH must be set"); + let wallet = Keypair::read_from_file(&keypair_path)?; + + let provider = Provider::new(env::var("RPC_URL").unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".to_string())); + let meteora = Meteora::new(); + + let sol_mint = Pubkey::from_str("So11111111111111111111111111111111111111112")?; + let usdc_mint = Pubkey::from_str(USDC_MINT)?; + + // Attempt to swap more SOL than the wallet has + let wallet_balance = provider.rpc_client.get_balance(&wallet.pubkey()).await?; + let result = meteora.swap( + sol_mint, + usdc_mint, + wallet_balance + 1, // More than wallet has + 100, + &wallet, + &provider, + true, + ).await; + + assert!(result.is_err(), "Swap with insufficient funds should fail"); + Ok(()) + } + + #[tokio::test] + async fn test_claim_fees() -> Result<()> { + let keypair_path = env::var("FUND_KEYPAIR_PATH").expect("FUND_KEYPAIR_PATH must be set"); + let wallet = Keypair::read_from_file(&keypair_path)?; + + let provider = Provider::new(env::var("RPC_URL").unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".to_string())); + let meteora = Meteora::new(); + + let sol_mint = Pubkey::from_str("So11111111111111111111111111111111111111112")?; + let usdc_mint = Pubkey::from_str(USDC_MINT)?; + + let lb_clmm = LbClmm::new(provider.rpc_client.as_ref()); + let pool = lb_clmm.get_lb_pair(sol_mint, usdc_mint).await?; + + // Add liquidity with 5x the base amount + // Note: We're using a relatively small amount since we'll be placing liquidity + // in actively traded bins of the SOL-USDC pair, which typically has high volume + meteora.add_liquidity( + &pool, + SOL_AMOUNT * 5, // 5x base amount for meaningful fee generation + 5_000, // 0.005 USDC + &wallet, + &provider, + ).await?; + + // Get position + let positions = lb_clmm.get_positions_by_owner(wallet.pubkey()).await?; + let position = positions.first().expect("No position found"); + + // Get initial balances + let initial_sol = provider.rpc_client.get_balance(&wallet.pubkey()).await?; + let initial_usdc = get_token_balance(&provider, usdc_mint, wallet.pubkey()).await?; + + // Wait for some trading activity to generate fees + info!("Waiting for trading activity to generate fees..."); + thread::sleep(Duration::from_secs(60)); + + // Claim fees + meteora.claim_fees( + &pool, + position, + &wallet, + &provider, + ).await?; + + // Verify balances after claiming fees + let final_sol = provider.rpc_client.get_balance(&wallet.pubkey()).await?; + let final_usdc = get_token_balance(&provider, usdc_mint, wallet.pubkey()).await?; + + // Note: In a real scenario, at least one of these should increase if fees were generated + if final_sol <= initial_sol && final_usdc <= initial_usdc { + info!("No fees were generated during the test period"); + } + + // Clean up by removing liquidity + meteora.remove_liquidity( + &pool, + position, + &wallet, + &provider, + ).await?; + + Ok(()) + } +} \ No newline at end of file