Skip to main content

nautilus_lighter/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Raw and domain HTTP clients for Lighter REST endpoints.
17
18use std::{collections::HashMap, sync::Arc, time::Duration};
19
20use jiff::Timestamp;
21use nautilus_core::{
22    AtomicTime, UnixNanos, string::secret::SecretString, time::get_atomic_clock_realtime,
23};
24use nautilus_model::{
25    data::{Bar, BarType, FundingRateUpdate, OrderBookDeltas, TradeTick},
26    identifiers::InstrumentId,
27    instruments::{Instrument, InstrumentAny},
28};
29use nautilus_network::{
30    http::{
31        HttpClient, HttpRedirectPolicy, HttpResponse, Method, create_standard_nautilus_headers,
32        parse_retry_after,
33    },
34    ratelimiter::quota::Quota,
35    retry::{RetryManager, create_http_retry_manager},
36};
37use rust_decimal::Decimal;
38use serde::{Serialize, de::DeserializeOwned};
39use url::form_urlencoded;
40use zeroize::Zeroizing;
41
42use crate::{
43    common::{
44        enums::{
45            LighterCandleResolution, LighterEnvironment, LighterFundingResolution,
46            LighterMarketStatus,
47        },
48        rate_limit::{
49            LIGHTER_REST_BUCKET, LIGHTER_REST_QUOTA, LighterTxRateLimiter, await_tx_quota,
50        },
51        symbol::MarketRegistry,
52        urls::lighter_http_base_url,
53    },
54    http::{
55        error::{
56            LighterHttpError, LighterHttpResult, create_lighter_http_timeout_error,
57            should_retry_lighter_http_error,
58        },
59        models::{
60            LighterAccountDetail, LighterAccountsResponse, LighterCandle, LighterCandles,
61            LighterFundings, LighterMakerOnlyApiKeys, LighterNextNonce, LighterOrderBookDetails,
62            LighterOrderBookOrders, LighterOrderBooks, LighterOrders, LighterResultCode,
63            LighterSendTxBatchRequest, LighterSendTxBatchResponse, LighterSendTxRequest,
64            LighterSendTxResponse, LighterTrade, LighterTrades, LighterTx,
65        },
66        parse::{
67            parse_candle_bar, parse_funding_rate_update,
68            parse_order_book_details_instruments_with_status, parse_order_book_snapshot,
69            parse_trade_tick, register_order_books,
70        },
71        query::{
72            LighterAccountActiveOrdersQuery, LighterAccountInactiveOrdersQuery,
73            LighterAccountLookup, LighterAccountQuery, LighterCandlesQuery, LighterFundingsQuery,
74            LighterMakerOnlyApiKeysQuery, LighterNextNonceQuery, LighterOrderBookDetailsQuery,
75            LighterOrderBookOrdersQuery, LighterOrderBooksQuery, LighterRecentTradesQuery,
76            LighterTradesQuery, LighterTxLookup, LighterTxQuery,
77        },
78    },
79};
80
81const API_V1: &str = "/api/v1";
82const ENDPOINT_ACCOUNT: &str = "/api/v1/account";
83const ENDPOINT_ACCOUNT_ACTIVE_ORDERS: &str = "/api/v1/accountActiveOrders";
84const ENDPOINT_ACCOUNT_INACTIVE_ORDERS: &str = "/api/v1/accountInactiveOrders";
85const ENDPOINT_CANDLES: &str = "/api/v1/candles";
86const ENDPOINT_FUNDINGS: &str = "/api/v1/fundings";
87const ENDPOINT_MAKER_ONLY_API_KEYS: &str = "/api/v1/getMakerOnlyApiKeys";
88const ENDPOINT_NEXT_NONCE: &str = "/api/v1/nextNonce";
89const ENDPOINT_ORDER_BOOK_DETAILS: &str = "/api/v1/orderBookDetails";
90const ENDPOINT_ORDER_BOOK_ORDERS: &str = "/api/v1/orderBookOrders";
91const ENDPOINT_ORDER_BOOKS: &str = "/api/v1/orderBooks";
92const ENDPOINT_RECENT_TRADES: &str = "/api/v1/recentTrades";
93const ENDPOINT_REFERRAL_USE: &str = "/api/v1/referral/use";
94const ENDPOINT_SEND_TX: &str = "/api/v1/sendTx";
95const ENDPOINT_SEND_TX_BATCH: &str = "/api/v1/sendTxBatch";
96const ENDPOINT_TRADES: &str = "/api/v1/trades";
97const ENDPOINT_TX: &str = "/api/v1/tx";
98const HEADER_AUTHORIZATION: &str = "authorization";
99const HEADER_RETRY_AFTER: &str = "retry-after";
100const MULTIPART_BOUNDARY: &str = "nautilus-lighter-form-boundary";
101
102/// Maximum page size accepted by Lighter REST list endpoints (`/api/v1/trades`,
103/// `/api/v1/accountInactiveOrders`). Values above this trigger `20001 invalid
104/// param` from the venue, so reconciliation paginates at this cap and follows
105/// `next_cursor` until the response is empty.
106pub const LIGHTER_REST_PAGE_SIZE: u16 = 100;
107pub const LIGHTER_CANDLES_MAX_LIMIT: u16 = 500;
108
109/// Maximum rows returned per `/api/v1/fundings` call (the venue per-call cap).
110pub const LIGHTER_FUNDINGS_MAX_LIMIT: u16 = 100;
111
112const DEFAULT_BARS_LIMIT: usize = LIGHTER_CANDLES_MAX_LIMIT as usize;
113const DEFAULT_FUNDING_RATES_LIMIT: usize = 100;
114const MAX_BAR_REQUEST_PAGES: usize = 500;
115const MAX_FUNDING_REQUEST_PAGES: usize = 500;
116
117trait LighterResponseCheck {
118    fn response_code(&self) -> i32;
119    fn response_message(&self) -> Option<&str>;
120}
121
122macro_rules! impl_lighter_response_check {
123    ($($ty:ty),+ $(,)?) => {
124        $(
125            impl LighterResponseCheck for $ty {
126                fn response_code(&self) -> i32 {
127                    self.code
128                }
129
130                fn response_message(&self) -> Option<&str> {
131                    self.message.as_deref()
132                }
133            }
134        )+
135    };
136}
137
138impl_lighter_response_check!(
139    LighterAccountsResponse,
140    LighterCandles,
141    LighterFundings,
142    LighterMakerOnlyApiKeys,
143    LighterNextNonce,
144    LighterOrderBookDetails,
145    LighterOrderBookOrders,
146    LighterOrderBooks,
147    LighterOrders,
148    LighterResultCode,
149    LighterSendTxBatchResponse,
150    LighterSendTxResponse,
151    LighterTrades,
152    LighterTx,
153);
154
155/// Raw HTTP client for Lighter REST API operations.
156///
157/// This client owns the transport, base URL, default headers, and rate limit. Methods map directly
158/// to venue endpoints and return venue response models without converting to Nautilus domain types.
159#[derive(Clone, Debug)]
160pub struct LighterRawHttpClient {
161    base_url: String,
162    environment: LighterEnvironment,
163    client: HttpClient,
164    retry_manager: RetryManager<LighterHttpError>,
165    tx_rate_limiter: Option<Arc<LighterTxRateLimiter>>,
166}
167
168impl Default for LighterRawHttpClient {
169    fn default() -> Self {
170        Self::new(LighterEnvironment::Mainnet, None, 60, None)
171            .expect("failed to create default Lighter raw HTTP client")
172    }
173}
174
175impl LighterRawHttpClient {
176    /// Creates a new [`LighterRawHttpClient`].
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if the underlying HTTP client cannot be created.
181    pub fn new(
182        environment: LighterEnvironment,
183        base_url: Option<String>,
184        timeout_secs: u64,
185        proxy_url: Option<String>,
186    ) -> LighterHttpResult<Self> {
187        Self::new_with_quotas(
188            environment,
189            base_url,
190            timeout_secs,
191            proxy_url,
192            *LIGHTER_REST_QUOTA,
193            None,
194        )
195    }
196
197    /// Creates a new [`LighterRawHttpClient`] with an explicit read quota and an
198    /// optional shared transaction limiter.
199    ///
200    /// `default_quota` governs the REST read bucket ([`LIGHTER_REST_BUCKET`]),
201    /// resolved from the client's configured `rest_quota_per_min` (detected tier
202    /// only logs hints). `tx_rate_limiter` paces `sendTx` / `sendTxBatch`; the
203    /// execution client shares one limiter across this and the WebSocket
204    /// `sendTx` path so their combined rate honors the single venue tx bucket.
205    /// The data client passes `None` (it sends no transactions).
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the underlying HTTP client cannot be created.
210    pub fn new_with_quotas(
211        environment: LighterEnvironment,
212        base_url: Option<String>,
213        timeout_secs: u64,
214        proxy_url: Option<String>,
215        default_quota: Quota,
216        tx_rate_limiter: Option<Arc<LighterTxRateLimiter>>,
217    ) -> LighterHttpResult<Self> {
218        let base_url = base_url
219            .unwrap_or_else(|| lighter_http_base_url(environment).to_string())
220            .trim_end_matches('/')
221            .to_string();
222
223        Ok(Self {
224            base_url,
225            environment,
226            client: HttpClient::builder()
227                .redirect_policy(HttpRedirectPolicy::Reject)
228                .header_keys(vec![HEADER_RETRY_AFTER.to_string()])
229                .headers(Self::default_headers())
230                .default_quota(default_quota)
231                .timeout_secs(timeout_secs)
232                .maybe_proxy_url(proxy_url)
233                .build()?,
234            retry_manager: create_http_retry_manager(),
235            tx_rate_limiter,
236        })
237    }
238
239    /// Returns the configured REST base URL.
240    #[must_use]
241    pub fn base_url(&self) -> &str {
242        self.base_url.as_str()
243    }
244
245    /// Returns the configured Lighter environment.
246    #[must_use]
247    pub const fn environment(&self) -> LighterEnvironment {
248        self.environment
249    }
250
251    /// Overrides the REST base URL. Intended for mock-server tests.
252    pub fn set_base_url(&mut self, base_url: &str) {
253        self.base_url = base_url.trim_end_matches('/').to_string();
254    }
255
256    /// Overrides the retry manager. Intended for mock-server tests that need
257    /// shorter backoff than [`create_http_retry_manager`] produces.
258    pub fn set_retry_manager(&mut self, retry_manager: RetryManager<LighterHttpError>) {
259        self.retry_manager = retry_manager;
260    }
261
262    /// Calls `GET /api/v1/orderBooks`.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the request fails or the response is invalid.
267    pub async fn get_order_books(
268        &self,
269        query: &LighterOrderBooksQuery,
270    ) -> LighterHttpResult<LighterOrderBooks> {
271        self.send_get_request(ENDPOINT_ORDER_BOOKS, Some(query))
272            .await
273    }
274
275    /// Calls `GET /api/v1/orderBookDetails`.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if the request fails or the response is invalid.
280    pub async fn get_order_book_details(
281        &self,
282        query: &LighterOrderBookDetailsQuery,
283    ) -> LighterHttpResult<LighterOrderBookDetails> {
284        self.send_get_request(ENDPOINT_ORDER_BOOK_DETAILS, Some(query))
285            .await
286    }
287
288    /// Calls `GET /api/v1/orderBookOrders`.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if the request fails or the response is invalid.
293    pub async fn get_order_book_orders(
294        &self,
295        query: &LighterOrderBookOrdersQuery,
296    ) -> LighterHttpResult<LighterOrderBookOrders> {
297        self.send_get_request(ENDPOINT_ORDER_BOOK_ORDERS, Some(query))
298            .await
299    }
300
301    /// Calls `GET /api/v1/recentTrades`.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if the request fails or the response is invalid.
306    pub async fn get_recent_trades(
307        &self,
308        query: &LighterRecentTradesQuery,
309    ) -> LighterHttpResult<LighterTrades> {
310        self.send_get_request(ENDPOINT_RECENT_TRADES, Some(query))
311            .await
312    }
313
314    /// Calls `GET /api/v1/trades`.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the request fails or the response is invalid.
319    pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
320        self.send_get_request(ENDPOINT_TRADES, Some(query)).await
321    }
322
323    /// Calls `GET /api/v1/candles`.
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if the request fails or the response is invalid.
328    pub async fn get_candles(
329        &self,
330        query: &LighterCandlesQuery,
331    ) -> LighterHttpResult<LighterCandles> {
332        self.send_get_request(ENDPOINT_CANDLES, Some(query)).await
333    }
334
335    /// Calls `GET /api/v1/fundings`.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if the request fails or the response is invalid.
340    pub async fn get_fundings(
341        &self,
342        query: &LighterFundingsQuery,
343    ) -> LighterHttpResult<LighterFundings> {
344        self.send_get_request(ENDPOINT_FUNDINGS, Some(query)).await
345    }
346
347    /// Calls `GET /api/v1/account`.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error if the request fails or the response is invalid.
352    pub async fn get_account(
353        &self,
354        query: &LighterAccountQuery,
355    ) -> LighterHttpResult<LighterAccountsResponse> {
356        self.send_get_request(ENDPOINT_ACCOUNT, Some(query)).await
357    }
358
359    /// Calls `GET /api/v1/accountActiveOrders`.
360    ///
361    /// # Errors
362    ///
363    /// Returns an error if the request fails or the response is invalid.
364    pub async fn get_account_active_orders(
365        &self,
366        query: &LighterAccountActiveOrdersQuery,
367    ) -> LighterHttpResult<LighterOrders> {
368        self.send_get_request(ENDPOINT_ACCOUNT_ACTIVE_ORDERS, Some(query))
369            .await
370    }
371
372    /// Calls `GET /api/v1/accountInactiveOrders`.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if the request fails or the response is invalid.
377    pub async fn get_account_inactive_orders(
378        &self,
379        query: &LighterAccountInactiveOrdersQuery,
380    ) -> LighterHttpResult<LighterOrders> {
381        self.send_get_request(ENDPOINT_ACCOUNT_INACTIVE_ORDERS, Some(query))
382            .await
383    }
384
385    /// Calls `GET /api/v1/nextNonce`.
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if the request fails or the response is invalid.
390    pub async fn get_next_nonce(
391        &self,
392        query: &LighterNextNonceQuery,
393    ) -> LighterHttpResult<LighterNextNonce> {
394        self.send_get_request(ENDPOINT_NEXT_NONCE, Some(query))
395            .await
396    }
397
398    /// Calls `GET /api/v1/tx`.
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if the request fails or the response is invalid.
403    pub async fn get_tx(&self, query: &LighterTxQuery) -> LighterHttpResult<LighterTx> {
404        self.send_get_request(ENDPOINT_TX, Some(query)).await
405    }
406
407    /// Calls `GET /api/v1/getMakerOnlyApiKeys`.
408    ///
409    /// # Errors
410    ///
411    /// Returns an error if the request fails or the response is invalid.
412    pub async fn get_maker_only_api_keys(
413        &self,
414        query: &LighterMakerOnlyApiKeysQuery,
415    ) -> LighterHttpResult<LighterMakerOnlyApiKeys> {
416        let params = LighterMakerOnlyApiKeysParams {
417            account_index: query.account_index,
418        };
419        let headers = query
420            .authorization
421            .as_ref()
422            .or(query.auth.as_ref())
423            .map(|auth| {
424                HashMap::from([(
425                    HEADER_AUTHORIZATION.to_string(),
426                    auth.expose_secret().to_owned(),
427                )])
428            });
429        self.send_get_request_with_headers(ENDPOINT_MAKER_ONLY_API_KEYS, Some(&params), headers)
430            .await
431    }
432
433    /// Calls `POST /api/v1/referral/use`.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the request fails or the response is invalid.
438    pub async fn use_referral(
439        &self,
440        l1_address: &str,
441        referral_code: &str,
442        auth_token: &str,
443    ) -> LighterHttpResult<LighterResultCode> {
444        let fields = [("l1_address", l1_address), ("referral_code", referral_code)];
445        self.send_post_urlencoded(ENDPOINT_REFERRAL_USE, &fields, auth_token)
446            .await
447    }
448
449    /// Calls `POST /api/v1/sendTx`.
450    ///
451    /// # Errors
452    ///
453    /// Returns an error if the request fails or the response is invalid.
454    pub async fn send_tx(
455        &self,
456        request: &LighterSendTxRequest,
457    ) -> LighterHttpResult<LighterSendTxResponse> {
458        let fields = request.form_fields();
459        self.send_post_form(ENDPOINT_SEND_TX, &fields).await
460    }
461
462    /// Calls `POST /api/v1/sendTxBatch`.
463    ///
464    /// # Errors
465    ///
466    /// Returns an error if the request fails or the response is invalid.
467    pub async fn send_tx_batch(
468        &self,
469        request: &LighterSendTxBatchRequest,
470    ) -> LighterHttpResult<LighterSendTxBatchResponse> {
471        let fields = request.form_fields();
472        self.send_post_form(ENDPOINT_SEND_TX_BATCH, &fields).await
473    }
474
475    async fn send_get_request<T, P>(
476        &self,
477        endpoint: &str,
478        params: Option<&P>,
479    ) -> LighterHttpResult<T>
480    where
481        T: DeserializeOwned + LighterResponseCheck,
482        P: Serialize,
483    {
484        self.send_get_request_with_headers(endpoint, params, None)
485            .await
486    }
487
488    async fn send_get_request_with_headers<T, P>(
489        &self,
490        endpoint: &str,
491        params: Option<&P>,
492        headers: Option<HashMap<String, String>>,
493    ) -> LighterHttpResult<T>
494    where
495        T: DeserializeOwned + LighterResponseCheck,
496        P: Serialize,
497    {
498        let url = self.url(endpoint);
499        let rate_limit_keys = Self::rate_limit_keys(endpoint);
500        self.retry_manager
501            .invocation(
502                endpoint,
503                || {
504                    let url = url.clone();
505                    let rate_limit_keys = rate_limit_keys.clone();
506                    let headers = headers.clone();
507
508                    async move {
509                        let response = self
510                            .client
511                            .request_with_params(
512                                Method::GET,
513                                url,
514                                params,
515                                headers,
516                                None,
517                                None,
518                                Some(rate_limit_keys),
519                            )
520                            .await?;
521                        Self::parse_response(&response)
522                    }
523                },
524                should_retry_lighter_http_error,
525                |e| create_lighter_http_timeout_error(e.to_string()),
526            )
527            .retry_delay(&LighterHttpError::retry_after)
528            .execute()
529            .await
530    }
531
532    // Single-shot: sendTx / sendTxBatch carry a signed nonce; transport-layer
533    // retry could double-submit if the original landed and only the ack was lost.
534    async fn send_post_form<T>(
535        &self,
536        endpoint: &str,
537        fields: &[(&str, String)],
538    ) -> LighterHttpResult<T>
539    where
540        T: DeserializeOwned + LighterResponseCheck,
541    {
542        // send_post_form only carries sendTx / sendTxBatch. With a shared tx
543        // limiter (execution client) pace on it and pass no REST keys, so the
544        // read quota does not also cap transactions. Without one (e.g. the
545        // integrator-revoke utility) fall back to the internal REST limiter so
546        // the request is still bounded rather than unthrottled.
547        let rate_keys = match &self.tx_rate_limiter {
548            Some(limiter) => {
549                await_tx_quota(limiter).await;
550                None
551            }
552            None => Some(Self::rate_limit_keys(endpoint)),
553        };
554
555        let response = self
556            .client
557            .request(
558                Method::POST,
559                self.url(endpoint),
560                None,
561                Some(multipart_headers()),
562                Some(multipart_form_bytes(fields)),
563                None,
564                rate_keys,
565            )
566            .await?;
567
568        Self::parse_response(&response)
569    }
570
571    // Single-shot because referral use changes account-level state and the API
572    // does not document idempotency for a response lost after submission.
573    async fn send_post_urlencoded<T>(
574        &self,
575        endpoint: &str,
576        fields: &[(&str, &str)],
577        auth_token: &str,
578    ) -> LighterHttpResult<T>
579    where
580        T: DeserializeOwned + LighterResponseCheck,
581    {
582        let mut serializer = form_urlencoded::Serializer::new(String::new());
583        serializer.extend_pairs(fields.iter().copied());
584        let body = serializer.finish().into_bytes();
585
586        let headers = HashMap::from([
587            ("Accept".to_string(), "application/json".to_string()),
588            (
589                "Content-Type".to_string(),
590                "application/x-www-form-urlencoded".to_string(),
591            ),
592            (HEADER_AUTHORIZATION.to_string(), auth_token.to_string()),
593        ]);
594
595        let response = self
596            .client
597            .request(
598                Method::POST,
599                self.url(endpoint),
600                None,
601                Some(headers),
602                Some(body),
603                None,
604                Some(Self::rate_limit_keys(endpoint)),
605            )
606            .await?;
607
608        Self::parse_response(&response)
609    }
610
611    fn parse_response<T>(response: &HttpResponse) -> LighterHttpResult<T>
612    where
613        T: DeserializeOwned + LighterResponseCheck,
614    {
615        let retry_after = response.headers.get(HEADER_RETRY_AFTER).and_then(|value| {
616            let now = get_atomic_clock_realtime().get_time_ns().to_datetime_utc();
617            parse_retry_after(value, now)
618        });
619
620        if !response.status.is_success() {
621            let status = response.status.as_u16();
622            let body = String::from_utf8_lossy(&response.body).to_string();
623
624            // Status-first: a `{code,message}` body must not override the
625            // retry decision for 5xx / 429.
626            if status >= 500 {
627                return Err(LighterHttpError::Http {
628                    status,
629                    body,
630                    retry_after,
631                });
632            }
633
634            // HTTP 405 is a Lighter rate-limit status like 429 per the docs, not a method error
635            if status == 429 || status == 405 {
636                return Err(LighterHttpError::RateLimit {
637                    message: body,
638                    retry_after,
639                });
640            }
641
642            if let Ok(result) = serde_json::from_slice::<LighterResultCode>(&response.body)
643                && result.code != 200
644            {
645                Self::result_code_error(
646                    result.code,
647                    result.message.as_deref(),
648                    "HTTP error",
649                    retry_after,
650                )?;
651            }
652
653            return Err(LighterHttpError::Http {
654                status,
655                body,
656                retry_after,
657            });
658        }
659
660        let payload: T = match serde_json::from_slice(&response.body) {
661            Ok(payload) => payload,
662            Err(payload_error) => {
663                if let Ok(result) = serde_json::from_slice::<LighterResultCode>(&response.body) {
664                    Self::check_response(&result, retry_after)?;
665                }
666                return Err(payload_error.into());
667            }
668        };
669
670        Self::check_response(&payload, retry_after)?;
671        Ok(payload)
672    }
673
674    fn check_response<T: LighterResponseCheck>(
675        payload: &T,
676        retry_after: Option<Duration>,
677    ) -> LighterHttpResult<()> {
678        Self::result_code_error(
679            payload.response_code(),
680            payload.response_message(),
681            "Lighter request failed",
682            retry_after,
683        )
684    }
685
686    fn result_code_error(
687        code: i32,
688        message: Option<&str>,
689        default_message: &str,
690        retry_after: Option<Duration>,
691    ) -> LighterHttpResult<()> {
692        match code {
693            200 => Ok(()),
694            405 | 429 => Err(LighterHttpError::RateLimit {
695                message: message.unwrap_or("Lighter rate limit exceeded").to_string(),
696                retry_after,
697            }),
698            code => Err(venue_error(code, message, default_message)),
699        }
700    }
701
702    fn url(&self, endpoint: &str) -> String {
703        format!("{}{}", self.base_url, endpoint)
704    }
705
706    fn rate_limit_keys(endpoint: &str) -> Vec<String> {
707        let route = endpoint.strip_prefix(API_V1).unwrap_or(endpoint);
708        vec![
709            LIGHTER_REST_BUCKET.to_string(),
710            format!("lighter:{}", route.trim_start_matches('/')),
711        ]
712    }
713
714    fn default_headers() -> HashMap<String, String> {
715        create_standard_nautilus_headers().into_iter().collect()
716    }
717}
718
719#[derive(Serialize)]
720struct LighterMakerOnlyApiKeysParams {
721    account_index: i64,
722}
723
724fn multipart_headers() -> HashMap<String, String> {
725    HashMap::from([
726        ("Accept".to_string(), "application/json".to_string()),
727        (
728            "Content-Type".to_string(),
729            format!("multipart/form-data; boundary={MULTIPART_BOUNDARY}"),
730        ),
731    ])
732}
733
734fn multipart_form_bytes(fields: &[(&str, String)]) -> Vec<u8> {
735    let mut body = String::new();
736    for (name, value) in fields {
737        body.push_str("--");
738        body.push_str(MULTIPART_BOUNDARY);
739        body.push_str("\r\nContent-Disposition: form-data; name=\"");
740        body.push_str(name);
741        body.push_str("\"\r\n\r\n");
742        body.push_str(value);
743        body.push_str("\r\n");
744    }
745    body.push_str("--");
746    body.push_str(MULTIPART_BOUNDARY);
747    body.push_str("--\r\n");
748    body.into_bytes()
749}
750
751/// Domain HTTP client for Lighter REST operations.
752///
753/// This client wraps [`LighterRawHttpClient`] and converts selected endpoint responses into
754/// Nautilus domain data. Market metadata calls also populate the shared [`MarketRegistry`].
755#[derive(Clone, Debug)]
756pub struct LighterHttpClient {
757    pub(crate) inner: Arc<LighterRawHttpClient>,
758    market_registry: Arc<MarketRegistry>,
759    clock: &'static AtomicTime,
760}
761
762impl Default for LighterHttpClient {
763    fn default() -> Self {
764        Self::new(LighterEnvironment::Mainnet, None, 60, None)
765            .expect("failed to create default Lighter HTTP client")
766    }
767}
768
769impl LighterHttpClient {
770    /// Creates a new [`LighterHttpClient`].
771    ///
772    /// # Errors
773    ///
774    /// Returns an error if the underlying raw HTTP client cannot be created.
775    pub fn new(
776        environment: LighterEnvironment,
777        base_url: Option<String>,
778        timeout_secs: u64,
779        proxy_url: Option<String>,
780    ) -> LighterHttpResult<Self> {
781        let raw_client = LighterRawHttpClient::new(environment, base_url, timeout_secs, proxy_url)?;
782        Ok(Self::from_raw(raw_client))
783    }
784
785    /// Wraps an existing raw HTTP client.
786    #[must_use]
787    pub fn from_raw(raw_client: LighterRawHttpClient) -> Self {
788        Self::from_raw_with_registry(raw_client, Arc::new(MarketRegistry::new()))
789    }
790
791    /// Wraps an existing raw HTTP client and shared market registry.
792    #[must_use]
793    pub fn from_raw_with_registry(
794        raw_client: LighterRawHttpClient,
795        market_registry: Arc<MarketRegistry>,
796    ) -> Self {
797        Self {
798            inner: Arc::new(raw_client),
799            market_registry,
800            clock: get_atomic_clock_realtime(),
801        }
802    }
803
804    /// Returns the configured REST base URL.
805    #[must_use]
806    pub fn base_url(&self) -> &str {
807        self.inner.base_url()
808    }
809
810    /// Returns the configured Lighter environment.
811    #[must_use]
812    pub fn environment(&self) -> LighterEnvironment {
813        self.inner.environment()
814    }
815
816    /// Returns the shared market registry used by this client.
817    #[must_use]
818    pub fn market_registry(&self) -> Arc<MarketRegistry> {
819        self.market_registry.clone()
820    }
821
822    /// Overrides the REST base URL. Intended for mock-server tests.
823    ///
824    /// # Panics
825    ///
826    /// Panics if the raw client is shared by another [`Arc`].
827    pub fn set_base_url(&mut self, base_url: &str) {
828        Arc::get_mut(&mut self.inner)
829            .expect("cannot override URL: raw client is shared")
830            .set_base_url(base_url);
831    }
832
833    /// Calls `GET /api/v1/orderBooks` and registers returned markets.
834    ///
835    /// # Errors
836    ///
837    /// Returns an error if the request fails or the response is invalid.
838    pub async fn get_order_books(
839        &self,
840        query: &LighterOrderBooksQuery,
841    ) -> LighterHttpResult<LighterOrderBooks> {
842        let response = self.inner.get_order_books(query).await?;
843        register_order_books(&self.market_registry, &response.order_books);
844        Ok(response)
845    }
846
847    /// Calls `GET /api/v1/orderBookDetails` and registers returned markets.
848    ///
849    /// # Errors
850    ///
851    /// Returns an error if the request fails or the response is invalid.
852    pub async fn get_order_book_details(
853        &self,
854        query: &LighterOrderBookDetailsQuery,
855    ) -> LighterHttpResult<LighterOrderBookDetails> {
856        let response = self.inner.get_order_book_details(query).await?;
857        parse_order_book_details_instruments_with_status(
858            &self.market_registry,
859            &response.order_book_details,
860            &response.spot_order_book_details,
861            self.generate_ts_init(),
862        )?;
863        Ok(response)
864    }
865
866    /// Calls `GET /api/v1/orderBookOrders`.
867    ///
868    /// # Errors
869    ///
870    /// Returns an error if the request fails or the response is invalid.
871    pub async fn get_order_book_orders(
872        &self,
873        query: &LighterOrderBookOrdersQuery,
874    ) -> LighterHttpResult<LighterOrderBookOrders> {
875        self.inner.get_order_book_orders(query).await
876    }
877
878    /// Calls `GET /api/v1/recentTrades`.
879    ///
880    /// # Errors
881    ///
882    /// Returns an error if the request fails or the response is invalid.
883    pub async fn get_recent_trades(
884        &self,
885        query: &LighterRecentTradesQuery,
886    ) -> LighterHttpResult<LighterTrades> {
887        self.inner.get_recent_trades(query).await
888    }
889
890    /// Calls `GET /api/v1/trades`.
891    ///
892    /// # Errors
893    ///
894    /// Returns an error if the request fails or the response is invalid.
895    pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
896        self.inner.get_trades(query).await
897    }
898
899    /// Calls `GET /api/v1/candles`.
900    ///
901    /// # Errors
902    ///
903    /// Returns an error if the request fails or the response is invalid.
904    pub async fn get_candles(
905        &self,
906        query: &LighterCandlesQuery,
907    ) -> LighterHttpResult<LighterCandles> {
908        self.inner.get_candles(query).await
909    }
910
911    /// Calls `GET /api/v1/fundings`.
912    ///
913    /// # Errors
914    ///
915    /// Returns an error if the request fails or the response is invalid.
916    pub async fn get_fundings(
917        &self,
918        query: &LighterFundingsQuery,
919    ) -> LighterHttpResult<LighterFundings> {
920        self.inner.get_fundings(query).await
921    }
922
923    /// Fetches the account row for `account_index` via `GET /api/v1/account`.
924    ///
925    /// # Errors
926    ///
927    /// Returns an error if the request fails, the response is invalid, or the
928    /// venue returns no account for the index.
929    pub async fn get_account_detail(
930        &self,
931        account_index: i64,
932    ) -> LighterHttpResult<LighterAccountDetail> {
933        let query = LighterAccountQuery {
934            by: LighterAccountLookup::Index,
935            value: account_index.to_string(),
936        };
937        self.inner
938            .get_account(&query)
939            .await?
940            .accounts
941            .into_iter()
942            .next()
943            .ok_or_else(|| {
944                LighterHttpError::Parse(format!("no account returned for index {account_index}"))
945            })
946    }
947
948    /// Calls `GET /api/v1/accountActiveOrders`.
949    ///
950    /// # Errors
951    ///
952    /// Returns an error if the request fails or the response is invalid.
953    pub async fn get_account_active_orders(
954        &self,
955        query: &LighterAccountActiveOrdersQuery,
956    ) -> LighterHttpResult<LighterOrders> {
957        self.inner.get_account_active_orders(query).await
958    }
959
960    /// Calls `GET /api/v1/accountInactiveOrders`.
961    ///
962    /// # Errors
963    ///
964    /// Returns an error if the request fails or the response is invalid.
965    pub async fn get_account_inactive_orders(
966        &self,
967        query: &LighterAccountInactiveOrdersQuery,
968    ) -> LighterHttpResult<LighterOrders> {
969        self.inner.get_account_inactive_orders(query).await
970    }
971
972    /// Calls `GET /api/v1/nextNonce` for `(account_index, api_key_index)`.
973    ///
974    /// # Errors
975    ///
976    /// Returns an error if the request fails or the response is invalid.
977    pub async fn get_next_nonce(
978        &self,
979        account_index: i64,
980        api_key_index: u8,
981    ) -> LighterHttpResult<LighterNextNonce> {
982        let query = LighterNextNonceQuery {
983            account_index,
984            api_key_index,
985        };
986        self.inner.get_next_nonce(&query).await
987    }
988
989    /// Calls `GET /api/v1/tx` for `tx_hash`.
990    ///
991    /// # Errors
992    ///
993    /// Returns an error if the request fails or the response is invalid.
994    pub async fn get_tx(&self, tx_hash: impl Into<String>) -> LighterHttpResult<LighterTx> {
995        self.inner
996            .get_tx(&LighterTxQuery {
997                by: LighterTxLookup::Hash,
998                value: tx_hash.into(),
999            })
1000            .await
1001    }
1002
1003    /// Calls `GET /api/v1/getMakerOnlyApiKeys` for `account_index`.
1004    ///
1005    /// `auth_token` is the canonical Lighter auth string minted from the
1006    /// caller's credential.
1007    ///
1008    /// # Errors
1009    ///
1010    /// Returns an error if the request fails or the response is invalid.
1011    pub async fn get_maker_only_api_keys(
1012        &self,
1013        account_index: i64,
1014        auth_token: impl Into<SecretString>,
1015    ) -> LighterHttpResult<LighterMakerOnlyApiKeys> {
1016        let query = Zeroizing::new(LighterMakerOnlyApiKeysQuery {
1017            authorization: Some(auth_token.into()),
1018            auth: None,
1019            account_index,
1020        });
1021        self.inner.get_maker_only_api_keys(&query).await
1022    }
1023
1024    /// Applies `referral_code` to the L1 address.
1025    ///
1026    /// # Errors
1027    ///
1028    /// Returns an error if the request fails or the response is invalid.
1029    pub async fn use_referral(
1030        &self,
1031        l1_address: &str,
1032        referral_code: &str,
1033        auth_token: &SecretString,
1034    ) -> LighterHttpResult<LighterResultCode> {
1035        self.inner
1036            .use_referral(l1_address, referral_code, auth_token.expose_secret())
1037            .await
1038    }
1039
1040    /// Calls `POST /api/v1/sendTx`.
1041    ///
1042    /// # Errors
1043    ///
1044    /// Returns an error if the request fails or the response is invalid.
1045    pub async fn send_tx(
1046        &self,
1047        request: &LighterSendTxRequest,
1048    ) -> LighterHttpResult<LighterSendTxResponse> {
1049        self.inner.send_tx(request).await
1050    }
1051
1052    /// Calls `POST /api/v1/sendTxBatch`.
1053    ///
1054    /// # Errors
1055    ///
1056    /// Returns an error if the request fails or the response is invalid.
1057    pub async fn send_tx_batch(
1058        &self,
1059        request: &LighterSendTxBatchRequest,
1060    ) -> LighterHttpResult<LighterSendTxBatchResponse> {
1061        self.inner.send_tx_batch(request).await
1062    }
1063
1064    /// Requests recent trades for an instrument and parses them into [`TradeTick`]s.
1065    ///
1066    /// # Errors
1067    ///
1068    /// Returns an error if the instrument has not been registered, the request fails, or a trade
1069    /// cannot be parsed.
1070    pub async fn request_recent_trades(
1071        &self,
1072        instrument: &InstrumentAny,
1073        limit: u16,
1074    ) -> LighterHttpResult<Vec<TradeTick>> {
1075        let market_id = self.market_index(instrument)?;
1076        let query = LighterRecentTradesQuery { market_id, limit };
1077        let response = self.inner.get_recent_trades(&query).await?;
1078        self.parse_trade_ticks(&response.trades, instrument)
1079    }
1080
1081    /// Requests historical trades and parses them into [`TradeTick`]s.
1082    ///
1083    /// If `query.market_id` is `None`, the value is resolved from the shared market registry.
1084    ///
1085    /// # Errors
1086    ///
1087    /// Returns an error if the instrument has not been registered, the request fails, or a trade
1088    /// cannot be parsed.
1089    pub async fn request_trades(
1090        &self,
1091        instrument: &InstrumentAny,
1092        query: LighterTradesQuery,
1093    ) -> LighterHttpResult<Vec<TradeTick>> {
1094        let mut query = Zeroizing::new(query);
1095        if query.market_id.is_none() {
1096            query.market_id = Some(self.market_index(instrument)?);
1097        }
1098        let response = self.inner.get_trades(&query).await?;
1099        self.parse_trade_ticks(&response.trades, instrument)
1100    }
1101
1102    /// Requests historical candles and parses them into Nautilus [`Bar`]s.
1103    ///
1104    /// # Errors
1105    ///
1106    /// Returns an error if the instrument has not been registered, the bar
1107    /// type is unsupported, the request fails, the page cap leaves part of the
1108    /// requested range uncovered, or a candle cannot be parsed.
1109    pub async fn request_bars(
1110        &self,
1111        instrument: &InstrumentAny,
1112        bar_type: BarType,
1113        start: Option<Timestamp>,
1114        end: Option<Timestamp>,
1115        limit: Option<u32>,
1116    ) -> LighterHttpResult<Vec<Bar>> {
1117        let market_id = self.market_index(instrument)?;
1118        let resolution = LighterCandleResolution::try_from(&bar_type)?;
1119        let interval_ms = resolution.interval_millis();
1120        let now = Timestamp::now();
1121
1122        if let (Some(start), Some(end)) = (start, end)
1123            && start >= end
1124        {
1125            return Err(LighterHttpError::Parse(format!(
1126                "invalid bar request range: start={start}, end={end}",
1127            )));
1128        }
1129
1130        let end = end.unwrap_or(now).min(now);
1131
1132        if let Some(start) = start
1133            && start >= end
1134        {
1135            return Ok(Vec::new());
1136        }
1137
1138        let requested_limit = limit.filter(|n| *n > 0).map(|n| n as usize);
1139        let target_limit = requested_limit.unwrap_or(DEFAULT_BARS_LIMIT);
1140        let start_was_unspecified = start.is_none();
1141        let end_ms = end.as_millisecond().max(0);
1142        let now_ms = now.as_millisecond();
1143
1144        if end_ms == 0 {
1145            return Ok(Vec::new());
1146        }
1147
1148        let start_ms = start.map_or_else(
1149            || {
1150                let lookback_bars = target_limit.saturating_add(1);
1151                let lookback_bars = i64::try_from(lookback_bars).unwrap_or(i64::MAX);
1152                let lookback_ms = interval_ms.saturating_mul(lookback_bars);
1153                end_ms.saturating_sub(lookback_ms)
1154            },
1155            |dt| dt.as_millisecond().max(0),
1156        );
1157
1158        if start_ms >= end_ms {
1159            return Ok(Vec::new());
1160        }
1161
1162        let mut bars = Vec::new();
1163        let mut cursor_ms = start_ms;
1164        let mut pages = 0_usize;
1165        let page_span_ms = interval_ms.saturating_mul(i64::from(LIGHTER_CANDLES_MAX_LIMIT));
1166
1167        while cursor_ms < end_ms && pages < MAX_BAR_REQUEST_PAGES {
1168            if !start_was_unspecified
1169                && let Some(limit) = requested_limit
1170                && bars.len() >= limit
1171            {
1172                break;
1173            }
1174
1175            let window_end_ms = cursor_ms.saturating_add(page_span_ms).min(end_ms);
1176            if window_end_ms <= cursor_ms {
1177                break;
1178            }
1179
1180            let query = LighterCandlesQuery {
1181                market_id,
1182                resolution,
1183                start_timestamp: cursor_ms,
1184                end_timestamp: window_end_ms,
1185                count_back: i64::from(LIGHTER_CANDLES_MAX_LIMIT),
1186                set_timestamp_to_end: Some(false),
1187            };
1188            let response = self.get_candles(&query).await?;
1189            let mut page = self.parse_bars(&response.candles, instrument, bar_type)?;
1190
1191            page.sort_by_key(|bar| bar.ts_event);
1192            for bar in page {
1193                let bar_start_ms = i64::try_from(bar.ts_event.as_u64() / 1_000_000)
1194                    .map_err(|e| LighterHttpError::Parse(e.to_string()))?;
1195
1196                if bar_start_ms < cursor_ms
1197                    || bar_start_ms >= end_ms
1198                    || bar_start_ms.saturating_add(interval_ms) > now_ms
1199                {
1200                    continue;
1201                }
1202
1203                if bars
1204                    .last()
1205                    .is_some_and(|last: &Bar| last.ts_event == bar.ts_event)
1206                {
1207                    continue;
1208                }
1209                bars.push(bar);
1210
1211                if !start_was_unspecified
1212                    && let Some(limit) = requested_limit
1213                    && bars.len() >= limit
1214                {
1215                    break;
1216                }
1217            }
1218
1219            cursor_ms = window_end_ms;
1220            pages += 1;
1221        }
1222
1223        let limit_satisfied =
1224            !start_was_unspecified && requested_limit.is_some_and(|limit| bars.len() >= limit);
1225        if pages >= MAX_BAR_REQUEST_PAGES && cursor_ms < end_ms && !limit_satisfied {
1226            return Err(LighterHttpError::HistoryIncomplete {
1227                data_type: "bar",
1228                pages,
1229            });
1230        }
1231
1232        if start_was_unspecified && bars.len() > target_limit {
1233            bars = bars.split_off(bars.len() - target_limit);
1234        }
1235
1236        Ok(bars)
1237    }
1238
1239    /// Requests historical funding rates and parses them into Nautilus updates.
1240    ///
1241    /// Lighter's public `/api/v1/fundings` endpoint returns settled funding
1242    /// rows at hourly or daily resolution. The adapter requests hourly rows and
1243    /// returns them in chronological order.
1244    ///
1245    /// # Errors
1246    ///
1247    /// Returns an error if the instrument is not a perpetual, the instrument
1248    /// has not been registered, the request range is invalid, the page cap
1249    /// leaves part of the requested range uncovered, or a row cannot be parsed.
1250    pub async fn request_funding_rates(
1251        &self,
1252        instrument: &InstrumentAny,
1253        start: Option<Timestamp>,
1254        end: Option<Timestamp>,
1255        limit: Option<usize>,
1256    ) -> LighterHttpResult<Vec<FundingRateUpdate>> {
1257        if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
1258            return Err(LighterHttpError::Parse(format!(
1259                "funding rates are only available for perpetual instruments: {}",
1260                instrument.id()
1261            )));
1262        }
1263
1264        let market_id = self.market_index(instrument)?;
1265        let resolution = LighterFundingResolution::OneHour;
1266        let interval_ms = resolution.interval_millis();
1267        let now = Timestamp::now();
1268
1269        if let (Some(start), Some(end)) = (start, end)
1270            && start >= end
1271        {
1272            return Err(LighterHttpError::Parse(format!(
1273                "invalid funding request range: start={start}, end={end}",
1274            )));
1275        }
1276
1277        let end = end.unwrap_or(now).min(now);
1278
1279        if let Some(start) = start
1280            && start >= end
1281        {
1282            return Ok(Vec::new());
1283        }
1284
1285        let requested_limit = limit.filter(|n| *n > 0);
1286        let target_limit = requested_limit.unwrap_or(DEFAULT_FUNDING_RATES_LIMIT);
1287        let start_was_unspecified = start.is_none();
1288        let end_ms = end.as_millisecond().max(0);
1289
1290        if end_ms == 0 {
1291            return Ok(Vec::new());
1292        }
1293
1294        let start_ms = start.map_or_else(
1295            || {
1296                let lookback_rows = target_limit.saturating_add(1);
1297                let lookback_rows = i64::try_from(lookback_rows).unwrap_or(i64::MAX);
1298                let lookback_ms = interval_ms.saturating_mul(lookback_rows);
1299                end_ms.saturating_sub(lookback_ms)
1300            },
1301            |dt| dt.as_millisecond().max(0),
1302        );
1303
1304        if start_ms >= end_ms {
1305            return Ok(Vec::new());
1306        }
1307
1308        let ts_init = self.generate_ts_init();
1309        let interval = Some(resolution.interval_minutes());
1310        let mut funding_rates = Vec::new();
1311        let mut cursor_ms = start_ms;
1312        let mut pages = 0_usize;
1313        // `cap - 1`, not `cap`: the endpoint excludes `end_timestamp`, so a
1314        // full-cap span with `count_back = cap` would drop the row at the cursor.
1315        let page_span_ms =
1316            interval_ms.saturating_mul(i64::from(LIGHTER_FUNDINGS_MAX_LIMIT.saturating_sub(1)));
1317
1318        while cursor_ms < end_ms && pages < MAX_FUNDING_REQUEST_PAGES {
1319            if !start_was_unspecified
1320                && let Some(limit) = requested_limit
1321                && funding_rates.len() >= limit
1322            {
1323                break;
1324            }
1325
1326            let window_end_ms = cursor_ms.saturating_add(page_span_ms).min(end_ms);
1327            if window_end_ms <= cursor_ms {
1328                break;
1329            }
1330
1331            let query = LighterFundingsQuery {
1332                market_id,
1333                resolution,
1334                start_timestamp: cursor_ms,
1335                end_timestamp: window_end_ms,
1336                count_back: i64::from(LIGHTER_FUNDINGS_MAX_LIMIT),
1337            };
1338            let response = self.get_fundings(&query).await?;
1339
1340            let mut page = Vec::with_capacity(response.fundings.len());
1341            for funding in &response.fundings {
1342                let update = parse_funding_rate_update(funding, instrument.id(), interval, ts_init)
1343                    .map_err(LighterHttpError::from)?;
1344                let timestamp_ms = i64::try_from(update.ts_event.as_u64() / 1_000_000)
1345                    .map_err(|e| LighterHttpError::Parse(e.to_string()))?;
1346
1347                if timestamp_ms < cursor_ms || timestamp_ms > end_ms {
1348                    continue;
1349                }
1350                page.push(update);
1351            }
1352
1353            page.sort_by_key(|rate| rate.ts_event);
1354            for update in page {
1355                if funding_rates
1356                    .last()
1357                    .is_some_and(|last: &FundingRateUpdate| last.ts_event == update.ts_event)
1358                {
1359                    continue;
1360                }
1361                funding_rates.push(update);
1362
1363                if !start_was_unspecified
1364                    && let Some(limit) = requested_limit
1365                    && funding_rates.len() >= limit
1366                {
1367                    break;
1368                }
1369            }
1370
1371            cursor_ms = window_end_ms;
1372            pages += 1;
1373        }
1374
1375        let limit_satisfied = !start_was_unspecified
1376            && requested_limit.is_some_and(|limit| funding_rates.len() >= limit);
1377        if pages >= MAX_FUNDING_REQUEST_PAGES && cursor_ms < end_ms && !limit_satisfied {
1378            return Err(LighterHttpError::HistoryIncomplete {
1379                data_type: "funding rate",
1380                pages,
1381            });
1382        }
1383
1384        if start_was_unspecified && funding_rates.len() > target_limit {
1385            funding_rates = funding_rates.split_off(funding_rates.len() - target_limit);
1386        }
1387
1388        Ok(funding_rates)
1389    }
1390
1391    /// Requests all instruments from Lighter order book metadata.
1392    ///
1393    /// Lighter exposes market definitions through `GET /api/v1/orderBookDetails`.
1394    ///
1395    /// # Errors
1396    ///
1397    /// Returns an error if the request fails or nonempty metadata contains no valid instruments.
1398    pub async fn request_instruments(&self) -> LighterHttpResult<Vec<InstrumentAny>> {
1399        self.request_instruments_for_query(&LighterOrderBookDetailsQuery::default())
1400            .await
1401    }
1402
1403    /// Requests all instruments and their market statuses from Lighter order book metadata.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Returns an error if the request fails or nonempty metadata contains no valid instruments.
1408    pub async fn request_instruments_with_status(
1409        &self,
1410    ) -> LighterHttpResult<Vec<(InstrumentAny, LighterMarketStatus)>> {
1411        self.request_instruments_with_status_for_query(&LighterOrderBookDetailsQuery::default())
1412            .await
1413    }
1414
1415    /// Requests a single instrument from Lighter order book metadata.
1416    ///
1417    /// # Errors
1418    ///
1419    /// Returns an error if the request fails, the instrument is not found, or the instrument
1420    /// cannot be parsed.
1421    pub async fn request_instrument(
1422        &self,
1423        instrument_id: InstrumentId,
1424    ) -> LighterHttpResult<InstrumentAny> {
1425        self.request_instrument_with_status(instrument_id)
1426            .await
1427            .map(|(instrument, _)| instrument)
1428    }
1429
1430    /// Requests a single instrument and its market status from Lighter order book metadata.
1431    ///
1432    /// # Errors
1433    ///
1434    /// Returns an error if the request fails, the instrument is not found, or the instrument
1435    /// cannot be parsed.
1436    pub async fn request_instrument_with_status(
1437        &self,
1438        instrument_id: InstrumentId,
1439    ) -> LighterHttpResult<(InstrumentAny, LighterMarketStatus)> {
1440        let query = LighterOrderBookDetailsQuery {
1441            market_id: self.market_registry.market_index(&instrument_id),
1442            filter: None,
1443        };
1444        let instruments = self
1445            .request_instruments_with_status_for_query(&query)
1446            .await?;
1447
1448        instruments
1449            .into_iter()
1450            .find(|(instrument, _)| instrument.id() == instrument_id)
1451            .ok_or_else(|| LighterHttpError::Parse(format!("instrument {instrument_id} not found")))
1452    }
1453
1454    /// Requests an HTTP order book snapshot and parses it into Nautilus order book deltas.
1455    ///
1456    /// # Errors
1457    ///
1458    /// Returns an error if the instrument has not been registered, the request fails, or any level
1459    /// cannot be parsed.
1460    pub async fn request_order_book_snapshot(
1461        &self,
1462        instrument: &InstrumentAny,
1463        limit: u16,
1464    ) -> LighterHttpResult<OrderBookDeltas> {
1465        let query = LighterOrderBookOrdersQuery {
1466            market_id: self.market_index(instrument)?,
1467            limit,
1468        };
1469        let snapshot = self.inner.get_order_book_orders(&query).await?;
1470        let ts_init = self.generate_ts_init();
1471
1472        parse_order_book_snapshot(
1473            &snapshot,
1474            instrument.id(),
1475            instrument.price_precision(),
1476            instrument.size_precision(),
1477            ts_init,
1478            ts_init,
1479        )
1480        .map_err(LighterHttpError::from)
1481    }
1482
1483    async fn request_instruments_for_query(
1484        &self,
1485        query: &LighterOrderBookDetailsQuery,
1486    ) -> LighterHttpResult<Vec<InstrumentAny>> {
1487        self.request_instruments_with_status_for_query(query)
1488            .await
1489            .map(|instruments| {
1490                instruments
1491                    .into_iter()
1492                    .map(|(instrument, _)| instrument)
1493                    .collect()
1494            })
1495    }
1496
1497    async fn request_instruments_with_status_for_query(
1498        &self,
1499        query: &LighterOrderBookDetailsQuery,
1500    ) -> LighterHttpResult<Vec<(InstrumentAny, LighterMarketStatus)>> {
1501        let response = self.inner.get_order_book_details(query).await?;
1502        let ts_init = self.generate_ts_init();
1503        parse_order_book_details_instruments_with_status(
1504            &self.market_registry,
1505            &response.order_book_details,
1506            &response.spot_order_book_details,
1507            ts_init,
1508        )
1509        .map_err(LighterHttpError::from)
1510    }
1511
1512    fn parse_trade_ticks(
1513        &self,
1514        trades: &[LighterTrade],
1515        instrument: &InstrumentAny,
1516    ) -> LighterHttpResult<Vec<TradeTick>> {
1517        let ts_init = self.generate_ts_init();
1518        trades
1519            .iter()
1520            .map(|trade| parse_trade_tick(trade, instrument, ts_init).map_err(Into::into))
1521            .collect()
1522    }
1523
1524    fn parse_bars(
1525        &self,
1526        candles: &[LighterCandle],
1527        instrument: &InstrumentAny,
1528        bar_type: BarType,
1529    ) -> LighterHttpResult<Vec<Bar>> {
1530        let ts_init = self.generate_ts_init();
1531        candles
1532            .iter()
1533            .filter_map(|candle| {
1534                let has_positive_ohlc = candle.open > Decimal::ZERO
1535                    && candle.high > Decimal::ZERO
1536                    && candle.low > Decimal::ZERO
1537                    && candle.close > Decimal::ZERO;
1538
1539                if !has_positive_ohlc {
1540                    log::warn!(
1541                        "Skipping Lighter candle at timestamp {} with non-positive OHLC values",
1542                        candle.timestamp,
1543                    );
1544                    return None;
1545                }
1546
1547                Some(parse_candle_bar(candle, bar_type, instrument, ts_init).map_err(Into::into))
1548            })
1549            .collect()
1550    }
1551
1552    fn market_index(&self, instrument: &InstrumentAny) -> LighterHttpResult<i64> {
1553        self.market_registry
1554            .market_index(&instrument.id())
1555            .ok_or_else(|| {
1556                LighterHttpError::Parse(format!(
1557                    "market index not registered for instrument {}",
1558                    instrument.id()
1559                ))
1560            })
1561    }
1562
1563    fn generate_ts_init(&self) -> UnixNanos {
1564        self.clock.get_time_ns()
1565    }
1566}
1567
1568fn venue_error(code: i32, message: Option<&str>, default_message: &str) -> LighterHttpError {
1569    LighterHttpError::Venue {
1570        code: i64::from(code),
1571        message: message.unwrap_or(default_message).to_string(),
1572    }
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577    use nautilus_testkit::http::assert_http_redirect_rejected;
1578    use rstest::rstest;
1579
1580    use super::*;
1581
1582    #[tokio::test]
1583    async fn test_authenticated_client_rejects_redirects() {
1584        let client = LighterRawHttpClient::new(LighterEnvironment::Testnet, None, 3, None)
1585            .unwrap()
1586            .client;
1587        assert_http_redirect_rejected(|url| async move {
1588            client
1589                .get(url, None, None, Some(3), None)
1590                .await
1591                .unwrap()
1592                .status
1593                .as_u16()
1594        })
1595        .await;
1596    }
1597
1598    #[rstest]
1599    #[case(ENDPOINT_TRADES, "lighter:trades")]
1600    #[case(ENDPOINT_SEND_TX, "lighter:sendTx")]
1601    #[case(ENDPOINT_SEND_TX_BATCH, "lighter:sendTxBatch")]
1602    fn test_rate_limit_keys_uses_rest_bucket_and_route(
1603        #[case] endpoint: &str,
1604        #[case] route: &str,
1605    ) {
1606        // Transactions are paced by the shared tx limiter, not a separate HTTP
1607        // key, so every endpoint (reads and tx) keys uniformly on the rest bucket.
1608        assert_eq!(
1609            LighterRawHttpClient::rate_limit_keys(endpoint),
1610            vec![LIGHTER_REST_BUCKET.to_string(), route.to_string()],
1611        );
1612    }
1613}