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                format!("HTTP {} (body unreadable: {})", status, e)
303            }
304        };
305
306        let error_msg = serde_json::from_str::<ErrorResponse>(&error_text)
307            .map(|err| err.get_message())
308            .unwrap_or(error_text);
309
310        Self::map_status_error(status, error_msg)
311    }
312
313    /// Map HTTP status code to ApiError.
314    fn map_status_error(status: StatusCode, message: String) -> ApiError {
315        match status {
316            StatusCode::UNAUTHORIZED => ApiError::Unauthorized(message),
317            StatusCode::NOT_FOUND => ApiError::NotFound(message),
318            StatusCode::BAD_REQUEST => ApiError::BadRequest(message),
319            StatusCode::FORBIDDEN => ApiError::Forbidden(message),
320            StatusCode::CONFLICT => ApiError::Conflict(message),
321            StatusCode::TOO_MANY_REQUESTS => ApiError::RateLimited(message),
322            _ if status.is_server_error() => ApiError::ServerError(message),
323            _ => ApiError::UnexpectedStatus(status.as_u16(), message),
324        }
325    }
326
327    /// Check if a status code is retryable.
328    fn is_retryable_status(status: StatusCode) -> bool {
329        status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS
330    }
331
332    // =========================================================================
333    // Validation helpers
334    // =========================================================================
335
336    /// Validate that a string is valid Base58 (Solana pubkey format).
337    fn validate_base58(value: &str, field_name: &str) -> ApiResult<()> {
338        if value.is_empty() {
339            return Err(ApiError::InvalidParameter(format!("{} cannot be empty", field_name)));
340        }
341        bs58::decode(value)
342            .into_vec()
343            .map_err(|_| ApiError::InvalidParameter(format!("{} is not valid Base58", field_name)))?;
344        Ok(())
345    }
346
347    /// Validate that a signature is 128 hex characters (64 bytes).
348    fn validate_signature(sig: &str) -> ApiResult<()> {
349        if sig.len() != 128 {
350            return Err(ApiError::InvalidParameter(
351                format!("Signature must be 128 hex characters, got {}", sig.len())
352            ));
353        }
354        // Validate hex by attempting to decode
355        for chunk in sig.as_bytes().chunks(2) {
356            let hex_str = std::str::from_utf8(chunk).unwrap_or("");
357            u8::from_str_radix(hex_str, 16)
358                .map_err(|_| ApiError::InvalidParameter("Signature must contain only hex characters".to_string()))?;
359        }
360        Ok(())
361    }
362
363    /// Validate that a limit is within bounds.
364    fn validate_limit(limit: u32, max: u32) -> ApiResult<()> {
365        if limit == 0 || limit > max {
366            return Err(ApiError::InvalidParameter(format!("Limit must be 1-{}", max)));
367        }
368        Ok(())
369    }
370
371    // =========================================================================
372    // Health endpoints
373    // =========================================================================
374
375    /// Check API health.
376    ///
377    /// Returns `Ok(())` if the API is healthy.
378    pub async fn health_check(&self) -> ApiResult<()> {
379        let url = format!("{}/health", self.base_url);
380        // Health check is special - we just need success status, not JSON parsing
381        let response = self.http_client.get(&url).send().await?;
382        if response.status().is_success() {
383            Ok(())
384        } else {
385            Err(ApiError::ServerError("Health check failed".to_string()))
386        }
387    }
388
389    // =========================================================================
390    // Market endpoints
391    // =========================================================================
392
393    /// Get all markets.
394    ///
395    /// Returns a list of all markets with their metadata.
396    pub async fn get_markets(&self) -> ApiResult<MarketsResponse> {
397        let url = format!("{}/api/markets", self.base_url);
398        self.get(&url).await
399    }
400
401    /// Get market details by pubkey.
402    ///
403    /// Returns complete market information including deposit assets.
404    pub async fn get_market(&self, market_pubkey: &str) -> ApiResult<MarketInfoResponse> {
405        Self::validate_base58(market_pubkey, "market_pubkey")?;
406        let url = format!("{}/api/markets/{}", self.base_url, urlencoding::encode(market_pubkey));
407        self.get(&url).await
408    }
409
410    /// Get market by URL-friendly slug.
411    pub async fn get_market_by_slug(&self, slug: &str) -> ApiResult<MarketInfoResponse> {
412        if slug.is_empty() {
413            return Err(ApiError::InvalidParameter("slug cannot be empty".to_string()));
414        }
415        let url = format!("{}/api/markets/by-slug/{}", self.base_url, urlencoding::encode(slug));
416        self.get(&url).await
417    }
418
419    /// Get deposit assets for a market.
420    pub async fn get_deposit_assets(&self, market_pubkey: &str) -> ApiResult<DepositAssetsResponse> {
421        Self::validate_base58(market_pubkey, "market_pubkey")?;
422        let url = format!("{}/api/markets/{}/deposit-assets", self.base_url, urlencoding::encode(market_pubkey));
423        self.get(&url).await
424    }
425
426    // =========================================================================
427    // Orderbook endpoints
428    // =========================================================================
429
430    /// Get orderbook depth.
431    ///
432    /// Returns price levels for bids and asks.
433    ///
434    /// # Arguments
435    ///
436    /// * `orderbook_id` - Orderbook identifier (can be "orderbook_id" or "market_pubkey:orderbook_id")
437    /// * `depth` - Optional max price levels per side (0 or None = all)
438    pub async fn get_orderbook(
439        &self,
440        orderbook_id: &str,
441        depth: Option<u32>,
442    ) -> ApiResult<OrderbookResponse> {
443        let mut url = format!("{}/api/orderbook/{}", self.base_url, urlencoding::encode(orderbook_id));
444        if let Some(d) = depth {
445            url.push_str(&format!("?depth={}", d));
446        }
447        self.get(&url).await
448    }
449
450    // =========================================================================
451    // Order endpoints
452    // =========================================================================
453
454    /// Submit a new order.
455    ///
456    /// The order must be pre-signed with the maker's Ed25519 key.
457    pub async fn submit_order(&self, request: SubmitOrderRequest) -> ApiResult<OrderResponse> {
458        Self::validate_base58(&request.maker, "maker")?;
459        Self::validate_base58(&request.market_pubkey, "market_pubkey")?;
460        Self::validate_base58(&request.base_token, "base_token")?;
461        Self::validate_base58(&request.quote_token, "quote_token")?;
462        Self::validate_signature(&request.signature)?;
463
464        let url = format!("{}/api/orders/submit", self.base_url);
465        self.post(&url, &request).await
466    }
467
468    /// Submit a signed FullOrder to the API.
469    ///
470    /// Convenience method that converts the order and submits it.
471    /// This bridges on-chain order creation with REST API submission.
472    ///
473    /// # Arguments
474    ///
475    /// * `order` - A signed FullOrder (must have called `order.sign(&keypair)`)
476    /// * `orderbook_id` - Target orderbook (use `order.derive_orderbook_id()` or from market API)
477    ///
478    /// # Example
479    ///
480    /// ```rust,ignore
481    /// let mut order = FullOrder::new_bid(params);
482    /// order.sign(&keypair);
483    ///
484    /// let response = api_client
485    ///     .submit_full_order(&order, order.derive_orderbook_id())
486    ///     .await?;
487    /// ```
488    pub async fn submit_full_order(
489        &self,
490        order: &FullOrder,
491        orderbook_id: impl Into<String>,
492    ) -> ApiResult<OrderResponse> {
493        let request = order.to_submit_request(orderbook_id);
494        self.submit_order(request).await
495    }
496
497    /// Cancel a specific order.
498    ///
499    /// The maker must match the order creator.
500    pub async fn cancel_order(&self, order_hash: &str, maker: &str) -> ApiResult<CancelResponse> {
501        Self::validate_base58(maker, "maker")?;
502
503        let url = format!("{}/api/orders/cancel", self.base_url);
504        let request = CancelOrderRequest {
505            order_hash: order_hash.to_string(),
506            maker: maker.to_string(),
507        };
508        self.post(&url, &request).await
509    }
510
511    /// Cancel all orders for a user.
512    ///
513    /// Optionally filter by market.
514    pub async fn cancel_all_orders(
515        &self,
516        user_pubkey: &str,
517        market_pubkey: Option<&str>,
518    ) -> ApiResult<CancelAllResponse> {
519        Self::validate_base58(user_pubkey, "user_pubkey")?;
520        if let Some(market) = market_pubkey {
521            Self::validate_base58(market, "market_pubkey")?;
522        }
523
524        let url = format!("{}/api/orders/cancel-all", self.base_url);
525        let request = CancelAllOrdersRequest {
526            user_pubkey: user_pubkey.to_string(),
527            market_pubkey: market_pubkey.map(|s| s.to_string()),
528        };
529        self.post(&url, &request).await
530    }
531
532    // =========================================================================
533    // User endpoints
534    // =========================================================================
535
536    /// Get all positions for a user.
537    pub async fn get_user_positions(&self, user_pubkey: &str) -> ApiResult<PositionsResponse> {
538        Self::validate_base58(user_pubkey, "user_pubkey")?;
539        let url = format!("{}/api/users/{}/positions", self.base_url, urlencoding::encode(user_pubkey));
540        self.get(&url).await
541    }
542
543    /// Get user positions in a specific market.
544    pub async fn get_user_market_positions(
545        &self,
546        user_pubkey: &str,
547        market_pubkey: &str,
548    ) -> ApiResult<MarketPositionsResponse> {
549        Self::validate_base58(user_pubkey, "user_pubkey")?;
550        Self::validate_base58(market_pubkey, "market_pubkey")?;
551
552        let url = format!(
553            "{}/api/users/{}/markets/{}/positions",
554            self.base_url,
555            urlencoding::encode(user_pubkey),
556            urlencoding::encode(market_pubkey)
557        );
558        self.get(&url).await
559    }
560
561    /// Get all open orders and balances for a user.
562    pub async fn get_user_orders(&self, user_pubkey: &str) -> ApiResult<UserOrdersResponse> {
563        Self::validate_base58(user_pubkey, "user_pubkey")?;
564
565        let url = format!("{}/api/users/orders", self.base_url);
566        let request = GetUserOrdersRequest {
567            user_pubkey: user_pubkey.to_string(),
568        };
569        self.post(&url, &request).await
570    }
571
572    // =========================================================================
573    // Price history endpoints
574    // =========================================================================
575
576    /// Get historical price data (candlesticks).
577    pub async fn get_price_history(
578        &self,
579        params: PriceHistoryParams,
580    ) -> ApiResult<PriceHistoryResponse> {
581        if let Some(limit) = params.limit {
582            Self::validate_limit(limit, MAX_PAGINATION_LIMIT)?;
583        }
584
585        let mut url = format!(
586            "{}/api/price-history?orderbook_id={}",
587            self.base_url,
588            urlencoding::encode(&params.orderbook_id)
589        );
590
591        if let Some(resolution) = params.resolution {
592            url.push_str(&format!("&resolution={}", urlencoding::encode(&resolution.to_string())));
593        }
594        if let Some(from) = params.from {
595            url.push_str(&format!("&from={}", from));
596        }
597        if let Some(to) = params.to {
598            url.push_str(&format!("&to={}", to));
599        }
600        if let Some(cursor) = params.cursor {
601            url.push_str(&format!("&cursor={}", cursor));
602        }
603        if let Some(limit) = params.limit {
604            url.push_str(&format!("&limit={}", limit));
605        }
606        if let Some(include_ohlcv) = params.include_ohlcv {
607            url.push_str(&format!("&include_ohlcv={}", include_ohlcv));
608        }
609
610        self.get(&url).await
611    }
612
613    // =========================================================================
614    // Trade endpoints
615    // =========================================================================
616
617    /// Get executed trades.
618    pub async fn get_trades(&self, params: TradesParams) -> ApiResult<TradesResponse> {
619        if let Some(ref user_pubkey) = params.user_pubkey {
620            Self::validate_base58(user_pubkey, "user_pubkey")?;
621        }
622        if let Some(limit) = params.limit {
623            Self::validate_limit(limit, MAX_PAGINATION_LIMIT)?;
624        }
625
626        let mut url = format!(
627            "{}/api/trades?orderbook_id={}",
628            self.base_url,
629            urlencoding::encode(&params.orderbook_id)
630        );
631
632        if let Some(user_pubkey) = params.user_pubkey {
633            url.push_str(&format!("&user_pubkey={}", urlencoding::encode(&user_pubkey)));
634        }
635        if let Some(from) = params.from {
636            url.push_str(&format!("&from={}", from));
637        }
638        if let Some(to) = params.to {
639            url.push_str(&format!("&to={}", to));
640        }
641        if let Some(cursor) = params.cursor {
642            url.push_str(&format!("&cursor={}", cursor));
643        }
644        if let Some(limit) = params.limit {
645            url.push_str(&format!("&limit={}", limit));
646        }
647
648        self.get(&url).await
649    }
650
651    // =========================================================================
652    // Admin endpoints
653    // =========================================================================
654
655    /// Admin health check endpoint.
656    pub async fn admin_health_check(&self) -> ApiResult<AdminResponse> {
657        let url = format!("{}/api/admin/test", self.base_url);
658        self.get(&url).await
659    }
660
661    /// Create a new orderbook for a market.
662    pub async fn create_orderbook(
663        &self,
664        request: CreateOrderbookRequest,
665    ) -> ApiResult<CreateOrderbookResponse> {
666        Self::validate_base58(&request.market_pubkey, "market_pubkey")?;
667        Self::validate_base58(&request.base_token, "base_token")?;
668        Self::validate_base58(&request.quote_token, "quote_token")?;
669
670        let url = format!("{}/api/admin/create-orderbook", self.base_url);
671        self.post(&url, &request).await
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::shared::Resolution;
679
680    #[test]
681    fn test_client_creation() {
682        let client = LightconeApiClient::new("https://api.lightcone.xyz").unwrap();
683        assert_eq!(client.base_url(), "https://api.lightcone.xyz");
684    }
685
686    #[test]
687    fn test_client_builder() {
688        let client = LightconeApiClient::builder("https://api.lightcone.xyz/")
689            .timeout_secs(60)
690            .header("X-Custom", "test")
691            .build()
692            .unwrap();
693
694        // Base URL should have trailing slash removed
695        assert_eq!(client.base_url(), "https://api.lightcone.xyz");
696    }
697
698    #[test]
699    fn test_price_history_params() {
700        let params = PriceHistoryParams::new("orderbook1")
701            .with_resolution(Resolution::OneHour)
702            .with_time_range(1000, 2000)
703            .with_limit(100)
704            .with_ohlcv();
705
706        assert_eq!(params.orderbook_id, "orderbook1");
707        assert_eq!(params.resolution, Some(Resolution::OneHour));
708        assert_eq!(params.from, Some(1000));
709        assert_eq!(params.to, Some(2000));
710        assert_eq!(params.limit, Some(100));
711        assert_eq!(params.include_ohlcv, Some(true));
712    }
713
714    #[test]
715    fn test_trades_params() {
716        let params = TradesParams::new("orderbook1")
717            .with_user("user123")
718            .with_time_range(1000, 2000)
719            .with_cursor(50)
720            .with_limit(100);
721
722        assert_eq!(params.orderbook_id, "orderbook1");
723        assert_eq!(params.user_pubkey, Some("user123".to_string()));
724        assert_eq!(params.from, Some(1000));
725        assert_eq!(params.to, Some(2000));
726        assert_eq!(params.cursor, Some(50));
727        assert_eq!(params.limit, Some(100));
728    }
729
730    #[test]
731    fn test_create_orderbook_request() {
732        let request = CreateOrderbookRequest::new("market1", "base1", "quote1").with_tick_size(500);
733
734        assert_eq!(request.market_pubkey, "market1");
735        assert_eq!(request.base_token, "base1");
736        assert_eq!(request.quote_token, "quote1");
737        assert_eq!(request.tick_size, Some(500));
738    }
739
740    #[test]
741    fn test_retry_config() {
742        let config = RetryConfig::new(3)
743            .with_base_delay_ms(200)
744            .with_max_delay_ms(5000);
745
746        assert_eq!(config.max_retries, 3);
747        assert_eq!(config.base_delay_ms, 200);
748        assert_eq!(config.max_delay_ms, 5000);
749    }
750
751    #[test]
752    fn test_client_with_retry() {
753        let client = LightconeApiClient::builder("https://api.lightcone.xyz")
754            .with_retry(RetryConfig::new(3))
755            .build()
756            .unwrap();
757
758        assert_eq!(client.retry_config.max_retries, 3);
759    }
760
761    #[test]
762    fn test_retry_delay_calculation() {
763        let config = RetryConfig {
764            max_retries: 5,
765            base_delay_ms: 100,
766            max_delay_ms: 1000,
767        };
768
769        // First attempt: ~100ms (75-100ms with jitter)
770        let delay0 = config.delay_for_attempt(0);
771        assert!(delay0.as_millis() >= 75 && delay0.as_millis() <= 100);
772
773        // Second attempt: ~200ms (150-200ms with jitter)
774        let delay1 = config.delay_for_attempt(1);
775        assert!(delay1.as_millis() >= 150 && delay1.as_millis() <= 200);
776
777        // Fourth attempt would be 800ms, but capped at 1000ms max
778        let delay3 = config.delay_for_attempt(3);
779        assert!(delay3.as_millis() >= 600 && delay3.as_millis() <= 800);
780
781        // Large attempt: should be capped at max_delay
782        let delay10 = config.delay_for_attempt(10);
783        assert!(delay10.as_millis() >= 750 && delay10.as_millis() <= 1000);
784    }
785}