Skip to main content

lightcone_sdk/api/
client.rs

1//! Lightcone REST API client implementation.
2//!
3//! The [`LightconeApiClient`] provides a type-safe interface for interacting with
4//! the Lightcone REST API.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use lightcone_sdk::api::LightconeApiClient;
10//!
11//! #[tokio::main]
12//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
13//!     let client = LightconeApiClient::new("https://api.lightcone.xyz");
14//!
15//!     // Get all markets
16//!     let markets = client.get_markets().await?;
17//!     println!("Found {} markets", markets.total);
18//!
19//!     // Get orderbook
20//!     let orderbook = client.get_orderbook("orderbook_id", None).await?;
21//!     println!("Best bid: {:?}", orderbook.best_bid);
22//!
23//!     Ok(())
24//! }
25//! ```
26
27use std::time::Duration;
28
29use reqwest::{Client, StatusCode};
30
31use crate::api::error::{ApiError, ApiResult, ErrorResponse};
32use crate::api::types::*;
33use crate::program::orders::FullOrder;
34
35/// Default request timeout in seconds.
36const DEFAULT_TIMEOUT_SECS: u64 = 30;
37
38/// Maximum allowed limit for paginated API requests.
39const MAX_PAGINATION_LIMIT: u32 = 500;
40
41/// Retry configuration for the API client.
42#[derive(Debug, Clone)]
43pub struct RetryConfig {
44    /// Maximum number of retry attempts (0 = disabled)
45    pub max_retries: u32,
46    /// Base delay before first retry (ms)
47    pub base_delay_ms: u64,
48    /// Maximum delay between retries (ms)
49    pub max_delay_ms: u64,
50}
51
52impl Default for RetryConfig {
53    fn default() -> Self {
54        Self {
55            max_retries: 0,
56            base_delay_ms: 100,
57            max_delay_ms: 10_000,
58        }
59    }
60}
61
62impl RetryConfig {
63    /// Create a new retry config with the given max retries.
64    pub fn new(max_retries: u32) -> Self {
65        Self {
66            max_retries,
67            ..Default::default()
68        }
69    }
70
71    /// Set the base delay in milliseconds.
72    pub fn with_base_delay_ms(mut self, ms: u64) -> Self {
73        self.base_delay_ms = ms;
74        self
75    }
76
77    /// Set the maximum delay in milliseconds.
78    pub fn with_max_delay_ms(mut self, ms: u64) -> Self {
79        self.max_delay_ms = ms;
80        self
81    }
82
83    /// Calculate delay for a given attempt with exponential backoff and jitter.
84    fn delay_for_attempt(&self, attempt: u32) -> Duration {
85        let exp_delay = self.base_delay_ms.saturating_mul(1 << attempt.min(10));
86        let capped_delay = exp_delay.min(self.max_delay_ms);
87        // Add jitter: 75-100% of calculated delay
88        let jitter_range = capped_delay / 4;
89        let jitter = rand::random::<u64>() % (jitter_range + 1);
90        Duration::from_millis(capped_delay - jitter_range + jitter)
91    }
92}
93
94/// Builder for configuring [`LightconeApiClient`].
95#[derive(Debug, Clone)]
96pub struct LightconeApiClientBuilder {
97    base_url: String,
98    timeout: Duration,
99    default_headers: Vec<(String, String)>,
100    retry_config: RetryConfig,
101}
102
103impl LightconeApiClientBuilder {
104    /// Create a new builder with the given base URL.
105    pub fn new(base_url: impl Into<String>) -> Self {
106        Self {
107            base_url: base_url.into().trim_end_matches('/').to_string(),
108            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
109            default_headers: Vec::new(),
110            retry_config: RetryConfig::default(),
111        }
112    }
113
114    /// Set the request timeout.
115    pub fn timeout(mut self, timeout: Duration) -> Self {
116        self.timeout = timeout;
117        self
118    }
119
120    /// Set the timeout in seconds.
121    pub fn timeout_secs(mut self, secs: u64) -> Self {
122        self.timeout = Duration::from_secs(secs);
123        self
124    }
125
126    /// Add a default header to all requests.
127    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
128        self.default_headers.push((name.into(), value.into()));
129        self
130    }
131
132    /// Enable retries with exponential backoff.
133    ///
134    /// # Arguments
135    ///
136    /// * `config` - Retry configuration (use `RetryConfig::new(3)` for 3 retries with defaults)
137    pub fn with_retry(mut self, config: RetryConfig) -> Self {
138        self.retry_config = config;
139        self
140    }
141
142    /// Build the client.
143    pub fn build(self) -> ApiResult<LightconeApiClient> {
144        let mut builder = Client::builder()
145            .timeout(self.timeout)
146            .pool_max_idle_per_host(10);
147
148        // Build default headers
149        let mut headers = reqwest::header::HeaderMap::new();
150        headers.insert(
151            reqwest::header::CONTENT_TYPE,
152            reqwest::header::HeaderValue::from_static("application/json"),
153        );
154        headers.insert(
155            reqwest::header::ACCEPT,
156            reqwest::header::HeaderValue::from_static("application/json"),
157        );
158
159        for (name, value) in self.default_headers {
160            let header_name = reqwest::header::HeaderName::try_from(name.as_str())
161                .map_err(|e| ApiError::InvalidParameter(format!("Invalid header name '{}': {}", name, e)))?;
162            let header_value = reqwest::header::HeaderValue::from_str(&value)
163                .map_err(|e| ApiError::InvalidParameter(format!("Invalid header value for '{}': {}", name, e)))?;
164            headers.insert(header_name, header_value);
165        }
166
167        builder = builder.default_headers(headers);
168
169        let http_client = builder.build()?;
170
171        Ok(LightconeApiClient {
172            http_client,
173            base_url: self.base_url,
174            retry_config: self.retry_config,
175        })
176    }
177}
178
179/// Lightcone REST API client.
180///
181/// Provides methods for all Lightcone API endpoints including markets, orderbooks,
182/// orders, positions, and price history.
183#[derive(Debug, Clone)]
184pub struct LightconeApiClient {
185    http_client: Client,
186    base_url: String,
187    retry_config: RetryConfig,
188}
189
190impl LightconeApiClient {
191    /// Create a new client with the given base URL.
192    ///
193    /// Uses default settings (30s timeout, connection pooling).
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if the HTTP client cannot be initialized.
198    pub fn new(base_url: impl Into<String>) -> ApiResult<Self> {
199        LightconeApiClientBuilder::new(base_url).build()
200    }
201
202    /// Create a new client builder for custom configuration.
203    pub fn builder(base_url: impl Into<String>) -> LightconeApiClientBuilder {
204        LightconeApiClientBuilder::new(base_url)
205    }
206
207    /// Get the base URL.
208    pub fn base_url(&self) -> &str {
209        &self.base_url
210    }
211
212    // =========================================================================
213    // Internal helpers
214    // =========================================================================
215
216    /// Execute a GET request with optional retry logic.
217    async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> ApiResult<T> {
218        self.execute_with_retry(|| self.http_client.get(url).send()).await
219    }
220
221    /// Execute a POST request with JSON body and optional retry logic.
222    async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize + Clone>(
223        &self,
224        url: &str,
225        body: &B,
226    ) -> ApiResult<T> {
227        self.execute_with_retry(|| self.http_client.post(url).json(body).send()).await
228    }
229
230    /// Execute a request with retry logic.
231    async fn execute_with_retry<T, F, Fut>(&self, request_fn: F) -> ApiResult<T>
232    where
233        F: Fn() -> Fut,
234        Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
235        T: serde::de::DeserializeOwned,
236    {
237        let mut attempt = 0;
238
239        loop {
240            let result = request_fn().await;
241
242            match result {
243                Ok(response) => {
244                    let status = response.status();
245
246                    if status.is_success() {
247                        return response.json::<T>().await.map_err(|e| {
248                            ApiError::Deserialize(format!("Failed to deserialize response: {}", e))
249                        });
250                    }
251
252                    // Parse error response
253                    let error = self.parse_error_response(response).await;
254
255                    // Check if we should retry
256                    if attempt < self.retry_config.max_retries && Self::is_retryable_status(status) {
257                        let delay = self.retry_config.delay_for_attempt(attempt);
258                        tracing::debug!(
259                            attempt = attempt + 1,
260                            max_retries = self.retry_config.max_retries,
261                            delay_ms = delay.as_millis(),
262                            status = %status,
263                            "Retrying request after error"
264                        );
265                        tokio::time::sleep(delay).await;
266                        attempt += 1;
267                        continue;
268                    }
269
270                    return Err(error);
271                }
272                Err(e) => {
273                    let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
274
275                    if attempt < self.retry_config.max_retries && is_retryable {
276                        let delay = self.retry_config.delay_for_attempt(attempt);
277                        tracing::debug!(
278                            attempt = attempt + 1,
279                            max_retries = self.retry_config.max_retries,
280                            delay_ms = delay.as_millis(),
281                            error = %e,
282                            "Retrying request after network error"
283                        );
284                        tokio::time::sleep(delay).await;
285                        attempt += 1;
286                        continue;
287                    }
288
289                    return Err(ApiError::Http(e));
290                }
291            }
292        }
293    }
294
295    /// Parse an error response into an ApiError.
296    async fn parse_error_response(&self, response: reqwest::Response) -> ApiError {
297        let status = response.status();
298        let error_text = match response.text().await {
299            Ok(text) => text,
300            Err(e) => {
301                tracing::warn!("Failed to read error response body: {}", e);
302                return Self::map_status_error(
303                    status,
304                    ErrorResponse::from_text(format!("HTTP {} (body unreadable: {})", status, e)),
305                );
306            }
307        };
308
309        let error_response = serde_json::from_str::<ErrorResponse>(&error_text)
310            .unwrap_or_else(|_| ErrorResponse::from_text(error_text));
311
312        Self::map_status_error(status, error_response)
313    }
314
315    /// Map HTTP status code to ApiError.
316    fn map_status_error(status: StatusCode, response: ErrorResponse) -> ApiError {
317        match status {
318            StatusCode::UNAUTHORIZED => ApiError::Unauthorized(response),
319            StatusCode::NOT_FOUND => ApiError::NotFound(response),
320            StatusCode::BAD_REQUEST => ApiError::BadRequest(response),
321            StatusCode::FORBIDDEN => ApiError::Forbidden(response),
322            StatusCode::CONFLICT => ApiError::Conflict(response),
323            StatusCode::TOO_MANY_REQUESTS => ApiError::RateLimited(response),
324            _ if status.is_server_error() => ApiError::ServerError(response),
325            _ => ApiError::UnexpectedStatus(status.as_u16(), response),
326        }
327    }
328
329    /// Check if a status code is retryable.
330    fn is_retryable_status(status: StatusCode) -> bool {
331        status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS
332    }
333
334    // =========================================================================
335    // Validation helpers
336    // =========================================================================
337
338    /// Validate that a string is valid Base58 (Solana pubkey format).
339    fn validate_base58(value: &str, field_name: &str) -> ApiResult<()> {
340        if value.is_empty() {
341            return Err(ApiError::InvalidParameter(format!("{} cannot be empty", field_name)));
342        }
343        bs58::decode(value)
344            .into_vec()
345            .map_err(|_| ApiError::InvalidParameter(format!("{} is not valid Base58", field_name)))?;
346        Ok(())
347    }
348
349    /// Validate that a signature is 128 hex characters (64 bytes).
350    fn validate_signature(sig: &str) -> ApiResult<()> {
351        if sig.len() != 128 {
352            return Err(ApiError::InvalidParameter(
353                format!("Signature must be 128 hex characters, got {}", sig.len())
354            ));
355        }
356        // Validate hex by attempting to decode
357        for chunk in sig.as_bytes().chunks(2) {
358            let hex_str = std::str::from_utf8(chunk).unwrap_or("");
359            u8::from_str_radix(hex_str, 16)
360                .map_err(|_| ApiError::InvalidParameter("Signature must contain only hex characters".to_string()))?;
361        }
362        Ok(())
363    }
364
365    /// Validate that a limit is within bounds.
366    fn validate_limit(limit: u32, max: u32) -> ApiResult<()> {
367        if limit == 0 || limit > max {
368            return Err(ApiError::InvalidParameter(format!("Limit must be 1-{}", max)));
369        }
370        Ok(())
371    }
372
373    // =========================================================================
374    // Health endpoints
375    // =========================================================================
376
377    /// Check API health.
378    ///
379    /// Returns `Ok(())` if the API is healthy.
380    pub async fn health_check(&self) -> ApiResult<()> {
381        let url = format!("{}/health", self.base_url);
382        // Health check is special - we just need success status, not JSON parsing
383        let response = self.http_client.get(&url).send().await?;
384        if response.status().is_success() {
385            Ok(())
386        } else {
387            Err(ApiError::ServerError(ErrorResponse::from_text("Health check failed".to_string())))
388        }
389    }
390
391    // =========================================================================
392    // Market endpoints
393    // =========================================================================
394
395    /// Get all markets.
396    ///
397    /// Returns a list of all markets with their metadata.
398    pub async fn get_markets(&self) -> ApiResult<MarketsResponse> {
399        let url = format!("{}/api/markets", self.base_url);
400        self.get(&url).await
401    }
402
403    /// Get market details by pubkey.
404    ///
405    /// Returns complete market information including deposit assets.
406    pub async fn get_market(&self, market_pubkey: &str) -> ApiResult<MarketInfoResponse> {
407        Self::validate_base58(market_pubkey, "market_pubkey")?;
408        let url = format!("{}/api/markets/{}", self.base_url, urlencoding::encode(market_pubkey));
409        self.get(&url).await
410    }
411
412    /// Get market by URL-friendly slug.
413    pub async fn get_market_by_slug(&self, slug: &str) -> ApiResult<MarketInfoResponse> {
414        if slug.is_empty() {
415            return Err(ApiError::InvalidParameter("slug cannot be empty".to_string()));
416        }
417        let url = format!("{}/api/markets/by-slug/{}", self.base_url, urlencoding::encode(slug));
418        self.get(&url).await
419    }
420
421    /// Get deposit assets for a market.
422    pub async fn get_deposit_assets(&self, market_pubkey: &str) -> ApiResult<DepositAssetsResponse> {
423        Self::validate_base58(market_pubkey, "market_pubkey")?;
424        let url = format!("{}/api/markets/{}/deposit-assets", self.base_url, urlencoding::encode(market_pubkey));
425        self.get(&url).await
426    }
427
428    // =========================================================================
429    // Orderbook endpoints
430    // =========================================================================
431
432    /// Get orderbook depth.
433    ///
434    /// Returns price levels for bids and asks.
435    ///
436    /// # Arguments
437    ///
438    /// * `orderbook_id` - Orderbook identifier (can be "orderbook_id" or "market_pubkey:orderbook_id")
439    /// * `depth` - Optional max price levels per side (0 or None = all)
440    pub async fn get_orderbook(
441        &self,
442        orderbook_id: &str,
443        depth: Option<u32>,
444    ) -> ApiResult<OrderbookResponse> {
445        let mut url = format!("{}/api/orderbook/{}", self.base_url, urlencoding::encode(orderbook_id));
446        if let Some(d) = depth {
447            url.push_str(&format!("?depth={}", d));
448        }
449        self.get(&url).await
450    }
451
452    // =========================================================================
453    // Order endpoints
454    // =========================================================================
455
456    /// Submit a new order.
457    ///
458    /// The order must be pre-signed with the maker's Ed25519 key.
459    pub async fn submit_order(&self, request: SubmitOrderRequest) -> ApiResult<OrderResponse> {
460        Self::validate_base58(&request.maker, "maker")?;
461        Self::validate_base58(&request.market_pubkey, "market_pubkey")?;
462        Self::validate_base58(&request.base_token, "base_token")?;
463        Self::validate_base58(&request.quote_token, "quote_token")?;
464        Self::validate_signature(&request.signature)?;
465
466        let url = format!("{}/api/orders/submit", self.base_url);
467        self.post(&url, &request).await
468    }
469
470    /// Submit a signed FullOrder to the API.
471    ///
472    /// Convenience method that converts the order and submits it.
473    /// This bridges on-chain order creation with REST API submission.
474    ///
475    /// # Arguments
476    ///
477    /// * `order` - A signed FullOrder (must have called `order.sign(&keypair)`)
478    /// * `orderbook_id` - Target orderbook (use `order.derive_orderbook_id()` or from market API)
479    ///
480    /// # Example
481    ///
482    /// ```rust,ignore
483    /// let mut order = FullOrder::new_bid(params);
484    /// order.sign(&keypair);
485    ///
486    /// let response = api_client
487    ///     .submit_full_order(&order, order.derive_orderbook_id())
488    ///     .await?;
489    /// ```
490    pub async fn submit_full_order(
491        &self,
492        order: &FullOrder,
493        orderbook_id: impl Into<String>,
494    ) -> ApiResult<OrderResponse> {
495        let request = order.to_submit_request(orderbook_id);
496        self.submit_order(request).await
497    }
498
499    /// Cancel a specific order.
500    ///
501    /// The maker must match the order creator.
502    pub async fn cancel_order(&self, order_hash: &str, maker: &str) -> ApiResult<CancelResponse> {
503        Self::validate_base58(maker, "maker")?;
504
505        let url = format!("{}/api/orders/cancel", self.base_url);
506        let request = CancelOrderRequest {
507            order_hash: order_hash.to_string(),
508            maker: maker.to_string(),
509        };
510        self.post(&url, &request).await
511    }
512
513    /// Cancel all orders for a user.
514    ///
515    /// Optionally filter by market.
516    pub async fn cancel_all_orders(
517        &self,
518        user_pubkey: &str,
519        market_pubkey: Option<&str>,
520    ) -> ApiResult<CancelAllResponse> {
521        Self::validate_base58(user_pubkey, "user_pubkey")?;
522        if let Some(market) = market_pubkey {
523            Self::validate_base58(market, "market_pubkey")?;
524        }
525
526        let url = format!("{}/api/orders/cancel-all", self.base_url);
527        let request = CancelAllOrdersRequest {
528            user_pubkey: user_pubkey.to_string(),
529            market_pubkey: market_pubkey.map(|s| s.to_string()),
530        };
531        self.post(&url, &request).await
532    }
533
534    // =========================================================================
535    // User endpoints
536    // =========================================================================
537
538    /// Get all positions for a user.
539    pub async fn get_user_positions(&self, user_pubkey: &str) -> ApiResult<PositionsResponse> {
540        Self::validate_base58(user_pubkey, "user_pubkey")?;
541        let url = format!("{}/api/users/{}/positions", self.base_url, urlencoding::encode(user_pubkey));
542        self.get(&url).await
543    }
544
545    /// Get user positions in a specific market.
546    pub async fn get_user_market_positions(
547        &self,
548        user_pubkey: &str,
549        market_pubkey: &str,
550    ) -> ApiResult<MarketPositionsResponse> {
551        Self::validate_base58(user_pubkey, "user_pubkey")?;
552        Self::validate_base58(market_pubkey, "market_pubkey")?;
553
554        let url = format!(
555            "{}/api/users/{}/markets/{}/positions",
556            self.base_url,
557            urlencoding::encode(user_pubkey),
558            urlencoding::encode(market_pubkey)
559        );
560        self.get(&url).await
561    }
562
563    /// Get all open orders and balances for a user.
564    pub async fn get_user_orders(&self, user_pubkey: &str) -> ApiResult<UserOrdersResponse> {
565        Self::validate_base58(user_pubkey, "user_pubkey")?;
566
567        let url = format!("{}/api/users/orders", self.base_url);
568        let request = GetUserOrdersRequest {
569            user_pubkey: user_pubkey.to_string(),
570        };
571        self.post(&url, &request).await
572    }
573
574    // =========================================================================
575    // Price history endpoints
576    // =========================================================================
577
578    /// Get historical price data (candlesticks).
579    pub async fn get_price_history(
580        &self,
581        params: PriceHistoryParams,
582    ) -> ApiResult<PriceHistoryResponse> {
583        if let Some(limit) = params.limit {
584            Self::validate_limit(limit, MAX_PAGINATION_LIMIT)?;
585        }
586
587        let mut url = format!(
588            "{}/api/price-history?orderbook_id={}",
589            self.base_url,
590            urlencoding::encode(&params.orderbook_id)
591        );
592
593        if let Some(resolution) = params.resolution {
594            url.push_str(&format!("&resolution={}", urlencoding::encode(&resolution.to_string())));
595        }
596        if let Some(from) = params.from {
597            url.push_str(&format!("&from={}", from));
598        }
599        if let Some(to) = params.to {
600            url.push_str(&format!("&to={}", to));
601        }
602        if let Some(cursor) = params.cursor {
603            url.push_str(&format!("&cursor={}", cursor));
604        }
605        if let Some(limit) = params.limit {
606            url.push_str(&format!("&limit={}", limit));
607        }
608        if let Some(include_ohlcv) = params.include_ohlcv {
609            url.push_str(&format!("&include_ohlcv={}", include_ohlcv));
610        }
611
612        self.get(&url).await
613    }
614
615    // =========================================================================
616    // Trade endpoints
617    // =========================================================================
618
619    /// Get executed trades.
620    pub async fn get_trades(&self, params: TradesParams) -> ApiResult<TradesResponse> {
621        if let Some(ref user_pubkey) = params.user_pubkey {
622            Self::validate_base58(user_pubkey, "user_pubkey")?;
623        }
624        if let Some(limit) = params.limit {
625            Self::validate_limit(limit, MAX_PAGINATION_LIMIT)?;
626        }
627
628        let mut url = format!(
629            "{}/api/trades?orderbook_id={}",
630            self.base_url,
631            urlencoding::encode(&params.orderbook_id)
632        );
633
634        if let Some(user_pubkey) = params.user_pubkey {
635            url.push_str(&format!("&user_pubkey={}", urlencoding::encode(&user_pubkey)));
636        }
637        if let Some(from) = params.from {
638            url.push_str(&format!("&from={}", from));
639        }
640        if let Some(to) = params.to {
641            url.push_str(&format!("&to={}", to));
642        }
643        if let Some(cursor) = params.cursor {
644            url.push_str(&format!("&cursor={}", cursor));
645        }
646        if let Some(limit) = params.limit {
647            url.push_str(&format!("&limit={}", limit));
648        }
649
650        self.get(&url).await
651    }
652
653    // =========================================================================
654    // Admin endpoints
655    // =========================================================================
656
657    /// Admin health check endpoint.
658    pub async fn admin_health_check(&self) -> ApiResult<AdminResponse> {
659        let url = format!("{}/api/admin/test", self.base_url);
660        self.get(&url).await
661    }
662
663    /// Create a new orderbook for a market.
664    pub async fn create_orderbook(
665        &self,
666        request: CreateOrderbookRequest,
667    ) -> ApiResult<CreateOrderbookResponse> {
668        Self::validate_base58(&request.market_pubkey, "market_pubkey")?;
669        Self::validate_base58(&request.base_token, "base_token")?;
670        Self::validate_base58(&request.quote_token, "quote_token")?;
671
672        let url = format!("{}/api/admin/create-orderbook", self.base_url);
673        self.post(&url, &request).await
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::shared::Resolution;
681
682    #[test]
683    fn test_client_creation() {
684        let client = LightconeApiClient::new("https://api.lightcone.xyz").unwrap();
685        assert_eq!(client.base_url(), "https://api.lightcone.xyz");
686    }
687
688    #[test]
689    fn test_client_builder() {
690        let client = LightconeApiClient::builder("https://api.lightcone.xyz/")
691            .timeout_secs(60)
692            .header("X-Custom", "test")
693            .build()
694            .unwrap();
695
696        // Base URL should have trailing slash removed
697        assert_eq!(client.base_url(), "https://api.lightcone.xyz");
698    }
699
700    #[test]
701    fn test_price_history_params() {
702        let params = PriceHistoryParams::new("orderbook1")
703            .with_resolution(Resolution::OneHour)
704            .with_time_range(1000, 2000)
705            .with_limit(100)
706            .with_ohlcv();
707
708        assert_eq!(params.orderbook_id, "orderbook1");
709        assert_eq!(params.resolution, Some(Resolution::OneHour));
710        assert_eq!(params.from, Some(1000));
711        assert_eq!(params.to, Some(2000));
712        assert_eq!(params.limit, Some(100));
713        assert_eq!(params.include_ohlcv, Some(true));
714    }
715
716    #[test]
717    fn test_trades_params() {
718        let params = TradesParams::new("orderbook1")
719            .with_user("user123")
720            .with_time_range(1000, 2000)
721            .with_cursor(50)
722            .with_limit(100);
723
724        assert_eq!(params.orderbook_id, "orderbook1");
725        assert_eq!(params.user_pubkey, Some("user123".to_string()));
726        assert_eq!(params.from, Some(1000));
727        assert_eq!(params.to, Some(2000));
728        assert_eq!(params.cursor, Some(50));
729        assert_eq!(params.limit, Some(100));
730    }
731
732    #[test]
733    fn test_create_orderbook_request() {
734        let request = CreateOrderbookRequest::new("market1", "base1", "quote1").with_tick_size(500);
735
736        assert_eq!(request.market_pubkey, "market1");
737        assert_eq!(request.base_token, "base1");
738        assert_eq!(request.quote_token, "quote1");
739        assert_eq!(request.tick_size, Some(500));
740    }
741
742    #[test]
743    fn test_retry_config() {
744        let config = RetryConfig::new(3)
745            .with_base_delay_ms(200)
746            .with_max_delay_ms(5000);
747
748        assert_eq!(config.max_retries, 3);
749        assert_eq!(config.base_delay_ms, 200);
750        assert_eq!(config.max_delay_ms, 5000);
751    }
752
753    #[test]
754    fn test_client_with_retry() {
755        let client = LightconeApiClient::builder("https://api.lightcone.xyz")
756            .with_retry(RetryConfig::new(3))
757            .build()
758            .unwrap();
759
760        assert_eq!(client.retry_config.max_retries, 3);
761    }
762
763    #[test]
764    fn test_retry_delay_calculation() {
765        let config = RetryConfig {
766            max_retries: 5,
767            base_delay_ms: 100,
768            max_delay_ms: 1000,
769        };
770
771        // First attempt: ~100ms (75-100ms with jitter)
772        let delay0 = config.delay_for_attempt(0);
773        assert!(delay0.as_millis() >= 75 && delay0.as_millis() <= 100);
774
775        // Second attempt: ~200ms (150-200ms with jitter)
776        let delay1 = config.delay_for_attempt(1);
777        assert!(delay1.as_millis() >= 150 && delay1.as_millis() <= 200);
778
779        // Fourth attempt would be 800ms, but capped at 1000ms max
780        let delay3 = config.delay_for_attempt(3);
781        assert!(delay3.as_millis() >= 600 && delay3.as_millis() <= 800);
782
783        // Large attempt: should be capped at max_delay
784        let delay10 = config.delay_for_attempt(10);
785        assert!(delay10.as_millis() >= 750 && delay10.as_millis() <= 1000);
786    }
787}