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