1use 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
100pub const LIGHTER_REST_PAGE_SIZE: u16 = 100;
105pub const LIGHTER_CANDLES_MAX_LIMIT: u16 = 500;
106
107pub 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#[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 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 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 #[must_use]
238 pub fn base_url(&self) -> &str {
239 self.base_url.as_str()
240 }
241
242 #[must_use]
244 pub const fn environment(&self) -> LighterEnvironment {
245 self.environment
246 }
247
248 pub fn set_base_url(&mut self, base_url: &str) {
250 self.base_url = base_url.trim_end_matches('/').to_string();
251 }
252
253 pub fn set_retry_manager(&mut self, retry_manager: RetryManager<LighterHttpError>) {
256 self.retry_manager = retry_manager;
257 }
258
259 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 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 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 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 pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
317 self.send_get_request(ENDPOINT_TRADES, Some(query)).await
318 }
319
320 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 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 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 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 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 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 pub async fn get_tx(&self, query: &LighterTxQuery) -> LighterHttpResult<LighterTx> {
401 self.send_get_request(ENDPOINT_TX, Some(query)).await
402 }
403
404 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(¶ms), headers)
427 .await
428 }
429
430 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 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 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 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 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 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 if status >= 500 {
618 return Err(LighterHttpError::Http { status, body });
619 }
620
621 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#[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 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 #[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 #[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 #[must_use]
774 pub fn base_url(&self) -> &str {
775 self.inner.base_url()
776 }
777
778 #[must_use]
780 pub fn environment(&self) -> LighterEnvironment {
781 self.inner.environment()
782 }
783
784 #[must_use]
786 pub fn market_registry(&self) -> Arc<MarketRegistry> {
787 self.market_registry.clone()
788 }
789
790 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 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 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 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 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 pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
864 self.inner.get_trades(query).await
865 }
866
867 pub async fn get_candles(
873 &self,
874 query: &LighterCandlesQuery,
875 ) -> LighterHttpResult<LighterCandles> {
876 self.inner.get_candles(query).await
877 }
878
879 pub async fn get_fundings(
885 &self,
886 query: &LighterFundingsQuery,
887 ) -> LighterHttpResult<LighterFundings> {
888 self.inner.get_fundings(query).await
889 }
890
891 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 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 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 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 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 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 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 pub async fn send_tx(
1014 &self,
1015 request: &LighterSendTxRequest,
1016 ) -> LighterHttpResult<LighterSendTxResponse> {
1017 self.inner.send_tx(request).await
1018 }
1019
1020 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 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 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 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 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 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 pub async fn request_instruments(&self) -> LighterHttpResult<Vec<InstrumentAny>> {
1367 self.request_instruments_for_query(&LighterOrderBookDetailsQuery::default())
1368 .await
1369 }
1370
1371 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 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 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 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 assert_eq!(
1577 LighterRawHttpClient::rate_limit_keys(endpoint),
1578 vec![LIGHTER_REST_BUCKET.to_string(), route.to_string()],
1579 );
1580 }
1581}