lightspeed_sdk/
client.rs

1// src/client.rs
2#![allow(deprecated)]
3
4use crate::{LightspeedConfig, LightspeedError, Priority, TransactionResult};
5use solana_sdk::{
6    instruction::Instruction,
7    pubkey::Pubkey,
8    signature::Signature,
9    system_instruction,
10    transaction::Transaction,
11    signer::Signer,
12};
13use std::str::FromStr;
14use std::sync::Arc;
15use tokio::sync::Mutex;
16use url::Url;
17use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
18
19/// Lightspeed tip recipient address
20/// 
21/// All tip transactions are sent to this address to enable prioritized processing.
22pub const LIGHTSPEED_TIP_ADDRESS: &str = "53PhM3UTdMQWu5t81wcd35AHGc5xpmHoRjem7GQPvXjA";
23
24/// Minimum tip amount in lamports (0.0001 SOL)
25/// 
26/// Transactions with tips below this amount will be rejected to ensure
27/// meaningful prioritization.
28pub const MIN_TIP_LAMPORTS: u64 = 100_000;
29
30/// Lightspeed RPC client for prioritized transaction processing
31/// 
32/// The client handles authentication, tip management, and connection maintenance
33/// for interacting with the Lightspeed service. API keys are securely transmitted
34/// via the Authorization header on all requests.
35/// 
36/// ## Example
37/// 
38/// ```rust
39/// use lightspeed_sdk::{LightspeedClientBuilder, Priority};
40/// use solana_sdk::{signature::Keypair, signer::Signer};
41/// 
42/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
43/// let client = LightspeedClientBuilder::new("your-api-key")
44///     .svs_rpc_url("https://basic.rpc.solanavibestation.com") 
45///     .build()?;
46/// 
47/// // Send a transaction with automatic tip injection
48/// let payer = Keypair::new();
49/// // ... create instructions ...
50/// # Ok(())
51/// # }
52/// ```
53pub struct LightspeedClient {
54    pub(crate) config: LightspeedConfig,
55    http_client: reqwest::Client,
56    endpoint: Url,
57    tip_pubkey: Pubkey,
58    keep_alive_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
59}
60
61impl LightspeedClient {
62    /// Creates a new Lightspeed client with the provided configuration
63    /// 
64    /// ## Arguments
65    /// 
66    /// * `config` - Client configuration including API key, tier, and settings
67    /// 
68    /// ## Errors
69    ///
70    /// Returns an error if:
71    /// - The API key is empty
72    /// - The endpoint URL is invalid
73    /// - HTTP client initialization fails
74    pub fn new(config: LightspeedConfig) -> Result<Self, LightspeedError> {
75        if config.api_key.is_empty() {
76            return Err(LightspeedError::InvalidApiKey);
77        }
78
79        // Get the endpoint URL from config (handles both custom and SVS URLs)
80        let endpoint = config.get_endpoint()?;
81
82        let tip_pubkey = Pubkey::from_str(LIGHTSPEED_TIP_ADDRESS)
83            .expect("Invalid tip address constant");
84
85        // Configure HTTP client with authentication headers
86        let mut headers = reqwest::header::HeaderMap::new();
87        headers.insert(
88            reqwest::header::AUTHORIZATION,
89            reqwest::header::HeaderValue::from_str(&config.api_key)
90                .map_err(|_| LightspeedError::InvalidApiKey)?
91        );
92        headers.insert(
93            reqwest::header::CONTENT_TYPE,
94            reqwest::header::HeaderValue::from_static("application/json")
95        );
96        headers.insert(
97            reqwest::header::USER_AGENT,
98            reqwest::header::HeaderValue::from_static("lightspeed-sdk-rust/0.1.0")
99        );
100
101        let http_client = reqwest::Client::builder()
102            .default_headers(headers)
103            .timeout(config.timeout)
104            .build()?;
105
106        if config.debug {
107            log::debug!("Lightspeed endpoint configured: {}", endpoint);
108        }
109
110        Ok(Self {
111            config,
112            http_client,
113            endpoint,
114            tip_pubkey,
115            keep_alive_handle: Arc::new(Mutex::new(None)),
116        })
117    }
118    
119    /// Starts automatic keep-alive to maintain connection health
120    /// 
121    /// Spawns a background task that periodically sends keep-alive requests
122    /// to prevent connection timeouts. The interval is configured via
123    /// `LightspeedClientBuilder::keep_alive_interval()`.
124    /// 
125    /// ## Example
126    /// 
127    /// ```rust
128    /// # async fn example(client: lightspeed_sdk::LightspeedClient) -> Result<(), Box<dyn std::error::Error>> {
129    /// client.start_keep_alive().await?;
130    /// // Connection will be maintained automatically
131    /// # Ok(())
132    /// # }
133    /// ```
134    /// 
135    /// ## Errors
136    /// 
137    /// Returns `LightspeedError::KeepAliveAlreadyRunning` if keep-alive is already active.
138    pub async fn start_keep_alive(&self) -> Result<(), LightspeedError> {
139        let mut handle_guard = self.keep_alive_handle.lock().await;
140        
141        if handle_guard.is_some() {
142            if self.config.debug {
143                log::debug!("Keep-alive already running");
144            }
145            return Err(LightspeedError::KeepAliveAlreadyRunning);
146        }
147        
148        let client = self.clone_for_keep_alive();
149        let interval_duration = self.config.keep_alive_interval;
150
151        let task = tokio::spawn(async move {
152            let mut interval = tokio::time::interval(interval_duration);
153            loop {
154                interval.tick().await;
155                if let Err(e) = client.keep_alive().await {
156                    log::warn!("Keep-alive failed: {:?}", e);
157                }
158            }
159        });
160
161        *handle_guard = Some(task);
162        
163        if self.config.debug {
164            log::debug!("Keep-alive task started with interval {:?}", interval_duration);
165        }
166        
167        Ok(())
168    }
169
170    /// Sends a transaction with automatic tip injection using the default priority
171    /// 
172    /// A tip instruction is automatically appended to your transaction to ensure
173    /// prioritized processing. The tip amount is determined by the client's
174    /// default priority setting.
175    /// 
176    /// ## Arguments
177    /// 
178    /// * `instructions` - Transaction instructions to execute
179    /// * `payer` - Account paying for transaction fees and tip
180    /// * `signers` - All required transaction signers
181    /// * `recent_blockhash` - Recent blockhash from the cluster
182    /// 
183    /// ## Returns
184    /// 
185    /// Returns a `TransactionResult` containing the signature and tip amount.
186    /// 
187    /// ## Example
188    /// 
189    /// ```rust
190    /// # async fn example(client: lightspeed_sdk::LightspeedClient) -> Result<(), Box<dyn std::error::Error>> {
191    /// # use solana_sdk::{signature::Keypair, signer::Signer, system_instruction, hash::Hash};
192    /// let payer = Keypair::new();
193    /// let recipient = solana_sdk::pubkey::Pubkey::new_unique();
194    /// 
195    /// let instruction = system_instruction::transfer(
196    ///     &payer.pubkey(),
197    ///     &recipient,
198    ///     1_000_000,
199    /// );
200    /// 
201    /// let result = client.send_transaction(
202    ///     vec![instruction],
203    ///     &payer.pubkey(),
204    ///     &[&payer],
205    ///     Hash::default(), // Use real blockhash in production
206    /// ).await?;
207    /// 
208    /// println!("Transaction: {}", result.signature);
209    /// println!("Tip paid: {} lamports", result.tip_amount);
210    /// # Ok(())
211    /// # }
212    /// ```
213    pub async fn send_transaction<T: Signer>(
214        &self,
215        instructions: Vec<Instruction>,
216        payer: &Pubkey,
217        signers: &[&T],
218        recent_blockhash: solana_sdk::hash::Hash,
219    ) -> Result<TransactionResult, LightspeedError> {
220        self.send_transaction_with_priority(
221            instructions,
222            payer,
223            signers,
224            recent_blockhash,
225            self.config.default_priority
226        ).await
227    }
228
229    /// Sends a transaction with a specific priority level
230    /// 
231    /// Similar to `send_transaction` but allows overriding the default priority
232    /// for this specific transaction.
233    /// 
234    /// ## Arguments
235    /// 
236    /// * `instructions` - Transaction instructions to execute
237    /// * `payer` - Account paying for transaction fees and tip
238    /// * `signers` - All required transaction signers
239    /// * `recent_blockhash` - Recent blockhash from the cluster
240    /// * `priority` - Priority level for this transaction
241    /// 
242    /// ## Priority Levels
243    /// 
244    /// - `Priority::Minimum` - 0.0001 SOL tip
245    /// - `Priority::Standard` - 0.001 SOL tip
246    /// - `Priority::Rush` - 0.005 SOL tip
247    /// - `Priority::Custom(lamports)` - Custom tip amount
248    pub async fn send_transaction_with_priority<T: Signer>(
249        &self,
250        mut instructions: Vec<Instruction>,
251        payer: &Pubkey,
252        signers: &[&T],
253        recent_blockhash: solana_sdk::hash::Hash,
254        priority: Priority,
255    ) -> Result<TransactionResult, LightspeedError> {
256        let tip_amount = priority.to_lamports();
257
258        if tip_amount < MIN_TIP_LAMPORTS {
259            return Err(LightspeedError::TipBelowMinimum(tip_amount, MIN_TIP_LAMPORTS));
260        }
261
262        // Optional balance check before sending
263        if self.config.check_balance_before_send {
264            // Calculate total lamports needed
265            let mut transfer_total = 0u64;
266
267            // Sum up transfer amounts from system program instructions
268            for instruction in &instructions {
269                if instruction.program_id == solana_sdk::system_program::id()
270                    && instruction.data.len() >= 4 {
271                    // Check if this is a transfer instruction (instruction type 2)
272                    let instruction_type = u32::from_le_bytes([
273                        instruction.data[0],
274                        instruction.data[1],
275                        instruction.data[2],
276                        instruction.data[3],
277                    ]);
278
279                    if instruction_type == 2 && instruction.data.len() >= 12 {
280                        // Extract transfer amount (8 bytes after the 4-byte instruction type)
281                        let amount = u64::from_le_bytes([
282                            instruction.data[4],
283                            instruction.data[5],
284                            instruction.data[6],
285                            instruction.data[7],
286                            instruction.data[8],
287                            instruction.data[9],
288                            instruction.data[10],
289                            instruction.data[11],
290                        ]);
291                        transfer_total = transfer_total.saturating_add(amount);
292                    }
293                }
294            }
295
296            // Total needed: transfers + tip + transaction fee buffer (5000 lamports)
297            let total_needed = transfer_total
298                .saturating_add(tip_amount)
299                .saturating_add(5000);
300
301            if self.config.debug {
302                log::debug!(
303                    "Balance check: transfers={}, tip={}, fee_buffer=5000, total={}",
304                    transfer_total, tip_amount, total_needed
305                );
306            }
307
308            self.check_balance(payer, total_needed).await?;
309        }
310
311        // Append tip instruction
312        let tip_instruction = system_instruction::transfer(
313            payer,
314            &self.tip_pubkey,
315            tip_amount,
316        );
317        instructions.push(tip_instruction);
318
319        // Build and sign transaction
320        let mut transaction = Transaction::new_with_payer(
321            &instructions,
322            Some(payer),
323        );
324        transaction.sign(signers, recent_blockhash);
325
326        // Submit to Lightspeed
327        let signature = self.send_transaction_internal(&transaction).await?;
328
329        Ok(TransactionResult {
330            signature,
331            tip_amount,
332        })
333    }
334
335    /// Sends a transaction with a custom tip amount
336    /// 
337    /// Provides direct control over the tip amount in lamports.
338    /// 
339    /// ## Arguments
340    /// 
341    /// * `instructions` - Transaction instructions to execute
342    /// * `payer` - Account paying for transaction fees and tip
343    /// * `signers` - All required transaction signers
344    /// * `recent_blockhash` - Recent blockhash from the cluster
345    /// * `tip_lamports` - Tip amount in lamports
346    pub async fn send_transaction_with_tip<T: Signer>(
347        &self,
348        instructions: Vec<Instruction>,
349        payer: &Pubkey,
350        signers: &[&T],
351        recent_blockhash: solana_sdk::hash::Hash,
352        tip_lamports: u64,
353    ) -> Result<TransactionResult, LightspeedError> {
354        self.send_transaction_with_priority(
355            instructions,
356            payer,
357            signers,
358            recent_blockhash,
359            Priority::Custom(tip_lamports),
360        ).await
361    }
362
363    /// Creates a tip instruction using the default priority
364    /// 
365    /// Use this when manually constructing transactions that need tip instructions.
366    /// 
367    /// ## Arguments
368    /// 
369    /// * `payer` - Account that will pay the tip
370    pub fn create_tip_instruction(&self, payer: &Pubkey) -> Instruction {
371        let tip_amount = self.config.default_priority.to_lamports();
372        system_instruction::transfer(
373            payer,
374            &self.tip_pubkey,
375            tip_amount,
376        )
377    }
378
379    /// Creates a tip instruction with a specific priority
380    /// 
381    /// ## Arguments
382    /// 
383    /// * `payer` - Account that will pay the tip
384    /// * `priority` - Priority level determining tip amount
385    pub fn create_tip_instruction_with_priority(&self, payer: &Pubkey, priority: Priority) -> Instruction {
386        let tip_amount = priority.to_lamports();
387        system_instruction::transfer(
388            payer,
389            &self.tip_pubkey,
390            tip_amount,
391        )
392    }
393
394    /// Creates a tip instruction with a custom amount
395    /// 
396    /// ## Arguments
397    /// 
398    /// * `payer` - Account that will pay the tip
399    /// * `tip_lamports` - Tip amount in lamports
400    pub fn create_tip_instruction_with_tip(&self, payer: &Pubkey, tip_lamports: u64) -> Instruction {
401        system_instruction::transfer(
402            payer,
403            &self.tip_pubkey,
404            tip_lamports,
405        )
406    }
407
408    /// Sends a pre-built transaction through Lightspeed
409    /// 
410    /// The transaction should already include a tip instruction. This method
411    /// provides direct control for advanced use cases.
412    /// 
413    /// ## Arguments
414    /// 
415    /// * `transaction` - Signed transaction including tip instruction
416    /// 
417    /// ## Example
418    /// 
419    /// ```rust
420    /// # async fn example(client: lightspeed_sdk::LightspeedClient) -> Result<(), Box<dyn std::error::Error>> {
421    /// # use solana_sdk::{signature::Keypair, signer::Signer, transaction::Transaction, hash::Hash};
422    /// let payer = Keypair::new();
423    /// 
424    /// // Build transaction with tip
425    /// let tip = client.create_tip_instruction(&payer.pubkey());
426    /// let mut tx = Transaction::new_with_payer(
427    ///     &[tip],
428    ///     Some(&payer.pubkey()),
429    /// );
430    /// tx.sign(&[&payer], Hash::default());
431    /// 
432    /// // Send through Lightspeed
433    /// let signature = client.send_prebuilt_transaction(&tx).await?;
434    /// # Ok(())
435    /// # }
436    /// ```
437    pub async fn send_prebuilt_transaction(
438        &self,
439        transaction: &Transaction,
440    ) -> Result<Signature, LightspeedError> {
441        if self.config.debug {
442            log::debug!("Sending transaction through Lightspeed");
443        }
444        self.send_transaction_internal(transaction).await
445    }
446
447    /// Updates the tip recipient address
448    /// 
449    /// ## Arguments
450    /// 
451    /// * `new_tip_address` - New tip address as a base58 string
452    /// 
453    /// ## Errors
454    /// 
455    /// Returns an error if the address is not a valid Solana public key.
456    pub fn set_tip_address(&mut self, new_tip_address: &str) -> Result<(), LightspeedError> {
457        let new_pubkey = Pubkey::from_str(new_tip_address)
458            .map_err(|_| LightspeedError::InvalidTipAddress(
459                new_tip_address.to_string()
460            ))?;
461        
462        self.tip_pubkey = new_pubkey;
463        
464        if self.config.debug {
465            log::debug!("Updated tip address to: {}", new_tip_address);
466        }
467        
468        Ok(())
469    }
470    
471    /// Returns the current tip recipient address
472    pub fn get_tip_address(&self) -> Pubkey {
473        self.tip_pubkey
474    }
475
476    /// Internal transaction submission handler
477    async fn send_transaction_internal(
478        &self,
479        transaction: &Transaction,
480    ) -> Result<Signature, LightspeedError> {
481        // Serialize transaction
482        let tx_bytes = bincode::serialize(&transaction)
483            .map_err(|e| LightspeedError::TransactionFailed(e.to_string()))?;
484        
485        // Encode as base64
486        let encoded = BASE64.encode(&tx_bytes);
487        
488        let request = serde_json::json!({
489            "jsonrpc": "2.0",
490            "id": 1,
491            "method": "sendTransaction",
492            "params": [
493                encoded,
494                {
495                    "skipPreflight": true,
496                    "encoding": "base64"
497                }
498            ]
499        });
500
501        if self.config.debug {
502            log::debug!("Sending transaction to endpoint: {}", self.endpoint);
503        }
504
505        let response = self.http_client
506            .post(self.endpoint.clone())
507            .json(&request)
508            .send()
509            .await?;
510
511        let response_text = response.text().await?;
512        
513        if self.config.debug {
514            log::debug!("Raw response: {}", response_text);
515        }
516
517        // Parse response
518        let result: serde_json::Value = serde_json::from_str(&response_text)
519            .map_err(|e| {
520                if self.config.debug {
521                    log::error!("Failed to parse response as JSON: {}", response_text);
522                }
523                LightspeedError::TransactionFailed(format!("Invalid JSON response: {}", e))
524            })?;
525        
526        if let Some(error) = result.get("error") {
527            return Err(LightspeedError::TransactionFailed(
528                error.to_string()
529            ));
530        }
531
532        let sig_str = result["result"]
533            .as_str()
534            .ok_or_else(|| LightspeedError::TransactionFailed("Invalid response".to_string()))?;
535
536        Signature::from_str(sig_str)
537            .map_err(|_| LightspeedError::TransactionFailed("Invalid signature".to_string()))
538    }
539
540    /// Sends a keep-alive request to maintain connection
541    ///
542    /// This is called automatically when keep-alive is enabled via `start_keep_alive()`.
543    /// Can also be called manually if needed.
544    pub async fn keep_alive(&self) -> Result<(), LightspeedError> {
545        let request = serde_json::json!({
546            "jsonrpc": "2.0",
547            "id": 1,
548            "method": "getHealth"
549        });
550
551        if self.config.debug {
552            log::debug!("Sending keep-alive");
553        }
554
555        self.http_client
556            .post(self.endpoint.clone())
557            .json(&request)
558            .send()
559            .await?;
560
561        Ok(())
562    }
563
564    /// Checks if an account has sufficient balance for a transaction
565    ///
566    /// Verifies that the account has enough lamports to cover the transaction
567    /// amount plus the tip. Includes a buffer for transaction fees (~5000 lamports).
568    ///
569    /// ## Arguments
570    ///
571    /// * `account` - The account to check
572    /// * `amount_needed` - Total lamports needed (transfer amount + tip + fee buffer)
573    ///
574    /// ## Returns
575    ///
576    /// Returns `Ok(())` if balance is sufficient, otherwise returns
577    /// `InsufficientBalance` error with needed and available amounts.
578    async fn check_balance(&self, account: &Pubkey, amount_needed: u64) -> Result<(), LightspeedError> {
579        let rpc_url = &self.config.balance_check_rpc_url;
580
581        let request = serde_json::json!({
582            "jsonrpc": "2.0",
583            "id": 1,
584            "method": "getBalance",
585            "params": [account.to_string()]
586        });
587
588        if self.config.debug {
589            log::debug!("Checking balance for account: {}", account);
590        }
591
592        let response = self.http_client
593            .post(rpc_url)
594            .json(&request)
595            .send()
596            .await?;
597
598        let result: serde_json::Value = response.json().await?;
599
600        if let Some(error) = result.get("error") {
601            return Err(LightspeedError::TransactionFailed(
602                format!("RPC balance check failed: {}", error)
603            ));
604        }
605
606        let balance = result["result"]["value"]
607            .as_u64()
608            .ok_or_else(|| LightspeedError::TransactionFailed(
609                "Invalid balance response from RPC".to_string()
610            ))?;
611
612        if self.config.debug {
613            log::debug!("Account balance: {} lamports, needed: {} lamports", balance, amount_needed);
614        }
615
616        if balance < amount_needed {
617            return Err(LightspeedError::InsufficientBalance(amount_needed, balance));
618        }
619
620        Ok(())
621    }
622
623    /// Creates a lightweight clone for the keep-alive task
624    fn clone_for_keep_alive(&self) -> Self {
625        Self {
626            config: self.config.clone(),
627            http_client: self.http_client.clone(),
628            endpoint: self.endpoint.clone(),
629            tip_pubkey: self.tip_pubkey,
630            keep_alive_handle: Arc::new(Mutex::new(None)),
631        }
632    }
633
634    /// Stops the automatic keep-alive task
635    /// 
636    /// ## Returns
637    /// 
638    /// Returns `true` if a keep-alive task was running and has been stopped,
639    /// `false` if no task was running.
640    /// 
641    /// ## Example
642    /// 
643    /// ```rust
644    /// # async fn example(client: lightspeed_sdk::LightspeedClient) -> Result<(), Box<dyn std::error::Error>> {
645    /// client.start_keep_alive().await?;
646    /// // ... do work ...
647    /// let was_running = client.stop_keep_alive().await;
648    /// assert!(was_running);
649    /// # Ok(())
650    /// # }
651    /// ```
652    pub async fn stop_keep_alive(&self) -> bool {
653        let mut handle_guard = self.keep_alive_handle.lock().await;
654        
655        if let Some(handle) = handle_guard.take() {
656            handle.abort();
657            if self.config.debug {
658                log::debug!("Keep-alive task stopped");
659            }
660            true
661        } else {
662            false
663        }
664    }
665}