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}