1use std::{collections::HashMap, sync::Arc, time::Duration};
19
20use jiff::Timestamp;
21use nautilus_core::{
22 AtomicTime, UnixNanos, string::secret::SecretString, time::get_atomic_clock_realtime,
23};
24use nautilus_model::{
25 data::{Bar, BarType, FundingRateUpdate, OrderBookDeltas, TradeTick},
26 identifiers::InstrumentId,
27 instruments::{Instrument, InstrumentAny},
28};
29use nautilus_network::{
30 http::{
31 HttpClient, HttpRedirectPolicy, HttpResponse, Method, create_standard_nautilus_headers,
32 parse_retry_after,
33 },
34 ratelimiter::quota::Quota,
35 retry::{RetryManager, create_http_retry_manager},
36};
37use rust_decimal::Decimal;
38use serde::{Serialize, de::DeserializeOwned};
39use url::form_urlencoded;
40use zeroize::Zeroizing;
41
42use crate::{
43 common::{
44 enums::{
45 LighterCandleResolution, LighterEnvironment, LighterFundingResolution,
46 LighterMarketStatus,
47 },
48 rate_limit::{
49 LIGHTER_REST_BUCKET, LIGHTER_REST_QUOTA, LighterTxRateLimiter, await_tx_quota,
50 },
51 symbol::MarketRegistry,
52 urls::lighter_http_base_url,
53 },
54 http::{
55 error::{
56 LighterHttpError, LighterHttpResult, create_lighter_http_timeout_error,
57 should_retry_lighter_http_error,
58 },
59 models::{
60 LighterAccountDetail, LighterAccountsResponse, LighterCandle, LighterCandles,
61 LighterFundings, LighterMakerOnlyApiKeys, LighterNextNonce, LighterOrderBookDetails,
62 LighterOrderBookOrders, LighterOrderBooks, LighterOrders, LighterResultCode,
63 LighterSendTxBatchRequest, LighterSendTxBatchResponse, LighterSendTxRequest,
64 LighterSendTxResponse, LighterTrade, LighterTrades, LighterTx,
65 },
66 parse::{
67 parse_candle_bar, parse_funding_rate_update,
68 parse_order_book_details_instruments_with_status, parse_order_book_snapshot,
69 parse_trade_tick, register_order_books,
70 },
71 query::{
72 LighterAccountActiveOrdersQuery, LighterAccountInactiveOrdersQuery,
73 LighterAccountLookup, LighterAccountQuery, LighterCandlesQuery, LighterFundingsQuery,
74 LighterMakerOnlyApiKeysQuery, LighterNextNonceQuery, LighterOrderBookDetailsQuery,
75 LighterOrderBookOrdersQuery, LighterOrderBooksQuery, LighterRecentTradesQuery,
76 LighterTradesQuery, LighterTxLookup, LighterTxQuery,
77 },
78 },
79};
80
81const API_V1: &str = "/api/v1";
82const ENDPOINT_ACCOUNT: &str = "/api/v1/account";
83const ENDPOINT_ACCOUNT_ACTIVE_ORDERS: &str = "/api/v1/accountActiveOrders";
84const ENDPOINT_ACCOUNT_INACTIVE_ORDERS: &str = "/api/v1/accountInactiveOrders";
85const ENDPOINT_CANDLES: &str = "/api/v1/candles";
86const ENDPOINT_FUNDINGS: &str = "/api/v1/fundings";
87const ENDPOINT_MAKER_ONLY_API_KEYS: &str = "/api/v1/getMakerOnlyApiKeys";
88const ENDPOINT_NEXT_NONCE: &str = "/api/v1/nextNonce";
89const ENDPOINT_ORDER_BOOK_DETAILS: &str = "/api/v1/orderBookDetails";
90const ENDPOINT_ORDER_BOOK_ORDERS: &str = "/api/v1/orderBookOrders";
91const ENDPOINT_ORDER_BOOKS: &str = "/api/v1/orderBooks";
92const ENDPOINT_RECENT_TRADES: &str = "/api/v1/recentTrades";
93const ENDPOINT_REFERRAL_USE: &str = "/api/v1/referral/use";
94const ENDPOINT_SEND_TX: &str = "/api/v1/sendTx";
95const ENDPOINT_SEND_TX_BATCH: &str = "/api/v1/sendTxBatch";
96const ENDPOINT_TRADES: &str = "/api/v1/trades";
97const ENDPOINT_TX: &str = "/api/v1/tx";
98const HEADER_AUTHORIZATION: &str = "authorization";
99const HEADER_RETRY_AFTER: &str = "retry-after";
100const MULTIPART_BOUNDARY: &str = "nautilus-lighter-form-boundary";
101
102pub const LIGHTER_REST_PAGE_SIZE: u16 = 100;
107pub const LIGHTER_CANDLES_MAX_LIMIT: u16 = 500;
108
109pub const LIGHTER_FUNDINGS_MAX_LIMIT: u16 = 100;
111
112const DEFAULT_BARS_LIMIT: usize = LIGHTER_CANDLES_MAX_LIMIT as usize;
113const DEFAULT_FUNDING_RATES_LIMIT: usize = 100;
114const MAX_BAR_REQUEST_PAGES: usize = 500;
115const MAX_FUNDING_REQUEST_PAGES: usize = 500;
116
117trait LighterResponseCheck {
118 fn response_code(&self) -> i32;
119 fn response_message(&self) -> Option<&str>;
120}
121
122macro_rules! impl_lighter_response_check {
123 ($($ty:ty),+ $(,)?) => {
124 $(
125 impl LighterResponseCheck for $ty {
126 fn response_code(&self) -> i32 {
127 self.code
128 }
129
130 fn response_message(&self) -> Option<&str> {
131 self.message.as_deref()
132 }
133 }
134 )+
135 };
136}
137
138impl_lighter_response_check!(
139 LighterAccountsResponse,
140 LighterCandles,
141 LighterFundings,
142 LighterMakerOnlyApiKeys,
143 LighterNextNonce,
144 LighterOrderBookDetails,
145 LighterOrderBookOrders,
146 LighterOrderBooks,
147 LighterOrders,
148 LighterResultCode,
149 LighterSendTxBatchResponse,
150 LighterSendTxResponse,
151 LighterTrades,
152 LighterTx,
153);
154
155#[derive(Clone, Debug)]
160pub struct LighterRawHttpClient {
161 base_url: String,
162 environment: LighterEnvironment,
163 client: HttpClient,
164 retry_manager: RetryManager<LighterHttpError>,
165 tx_rate_limiter: Option<Arc<LighterTxRateLimiter>>,
166}
167
168impl Default for LighterRawHttpClient {
169 fn default() -> Self {
170 Self::new(LighterEnvironment::Mainnet, None, 60, None)
171 .expect("failed to create default Lighter raw HTTP client")
172 }
173}
174
175impl LighterRawHttpClient {
176 pub fn new(
182 environment: LighterEnvironment,
183 base_url: Option<String>,
184 timeout_secs: u64,
185 proxy_url: Option<String>,
186 ) -> LighterHttpResult<Self> {
187 Self::new_with_quotas(
188 environment,
189 base_url,
190 timeout_secs,
191 proxy_url,
192 *LIGHTER_REST_QUOTA,
193 None,
194 )
195 }
196
197 pub fn new_with_quotas(
211 environment: LighterEnvironment,
212 base_url: Option<String>,
213 timeout_secs: u64,
214 proxy_url: Option<String>,
215 default_quota: Quota,
216 tx_rate_limiter: Option<Arc<LighterTxRateLimiter>>,
217 ) -> LighterHttpResult<Self> {
218 let base_url = base_url
219 .unwrap_or_else(|| lighter_http_base_url(environment).to_string())
220 .trim_end_matches('/')
221 .to_string();
222
223 Ok(Self {
224 base_url,
225 environment,
226 client: HttpClient::builder()
227 .redirect_policy(HttpRedirectPolicy::Reject)
228 .header_keys(vec![HEADER_RETRY_AFTER.to_string()])
229 .headers(Self::default_headers())
230 .default_quota(default_quota)
231 .timeout_secs(timeout_secs)
232 .maybe_proxy_url(proxy_url)
233 .build()?,
234 retry_manager: create_http_retry_manager(),
235 tx_rate_limiter,
236 })
237 }
238
239 #[must_use]
241 pub fn base_url(&self) -> &str {
242 self.base_url.as_str()
243 }
244
245 #[must_use]
247 pub const fn environment(&self) -> LighterEnvironment {
248 self.environment
249 }
250
251 pub fn set_base_url(&mut self, base_url: &str) {
253 self.base_url = base_url.trim_end_matches('/').to_string();
254 }
255
256 pub fn set_retry_manager(&mut self, retry_manager: RetryManager<LighterHttpError>) {
259 self.retry_manager = retry_manager;
260 }
261
262 pub async fn get_order_books(
268 &self,
269 query: &LighterOrderBooksQuery,
270 ) -> LighterHttpResult<LighterOrderBooks> {
271 self.send_get_request(ENDPOINT_ORDER_BOOKS, Some(query))
272 .await
273 }
274
275 pub async fn get_order_book_details(
281 &self,
282 query: &LighterOrderBookDetailsQuery,
283 ) -> LighterHttpResult<LighterOrderBookDetails> {
284 self.send_get_request(ENDPOINT_ORDER_BOOK_DETAILS, Some(query))
285 .await
286 }
287
288 pub async fn get_order_book_orders(
294 &self,
295 query: &LighterOrderBookOrdersQuery,
296 ) -> LighterHttpResult<LighterOrderBookOrders> {
297 self.send_get_request(ENDPOINT_ORDER_BOOK_ORDERS, Some(query))
298 .await
299 }
300
301 pub async fn get_recent_trades(
307 &self,
308 query: &LighterRecentTradesQuery,
309 ) -> LighterHttpResult<LighterTrades> {
310 self.send_get_request(ENDPOINT_RECENT_TRADES, Some(query))
311 .await
312 }
313
314 pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
320 self.send_get_request(ENDPOINT_TRADES, Some(query)).await
321 }
322
323 pub async fn get_candles(
329 &self,
330 query: &LighterCandlesQuery,
331 ) -> LighterHttpResult<LighterCandles> {
332 self.send_get_request(ENDPOINT_CANDLES, Some(query)).await
333 }
334
335 pub async fn get_fundings(
341 &self,
342 query: &LighterFundingsQuery,
343 ) -> LighterHttpResult<LighterFundings> {
344 self.send_get_request(ENDPOINT_FUNDINGS, Some(query)).await
345 }
346
347 pub async fn get_account(
353 &self,
354 query: &LighterAccountQuery,
355 ) -> LighterHttpResult<LighterAccountsResponse> {
356 self.send_get_request(ENDPOINT_ACCOUNT, Some(query)).await
357 }
358
359 pub async fn get_account_active_orders(
365 &self,
366 query: &LighterAccountActiveOrdersQuery,
367 ) -> LighterHttpResult<LighterOrders> {
368 self.send_get_request(ENDPOINT_ACCOUNT_ACTIVE_ORDERS, Some(query))
369 .await
370 }
371
372 pub async fn get_account_inactive_orders(
378 &self,
379 query: &LighterAccountInactiveOrdersQuery,
380 ) -> LighterHttpResult<LighterOrders> {
381 self.send_get_request(ENDPOINT_ACCOUNT_INACTIVE_ORDERS, Some(query))
382 .await
383 }
384
385 pub async fn get_next_nonce(
391 &self,
392 query: &LighterNextNonceQuery,
393 ) -> LighterHttpResult<LighterNextNonce> {
394 self.send_get_request(ENDPOINT_NEXT_NONCE, Some(query))
395 .await
396 }
397
398 pub async fn get_tx(&self, query: &LighterTxQuery) -> LighterHttpResult<LighterTx> {
404 self.send_get_request(ENDPOINT_TX, Some(query)).await
405 }
406
407 pub async fn get_maker_only_api_keys(
413 &self,
414 query: &LighterMakerOnlyApiKeysQuery,
415 ) -> LighterHttpResult<LighterMakerOnlyApiKeys> {
416 let params = LighterMakerOnlyApiKeysParams {
417 account_index: query.account_index,
418 };
419 let headers = query
420 .authorization
421 .as_ref()
422 .or(query.auth.as_ref())
423 .map(|auth| {
424 HashMap::from([(
425 HEADER_AUTHORIZATION.to_string(),
426 auth.expose_secret().to_owned(),
427 )])
428 });
429 self.send_get_request_with_headers(ENDPOINT_MAKER_ONLY_API_KEYS, Some(¶ms), headers)
430 .await
431 }
432
433 pub async fn use_referral(
439 &self,
440 l1_address: &str,
441 referral_code: &str,
442 auth_token: &str,
443 ) -> LighterHttpResult<LighterResultCode> {
444 let fields = [("l1_address", l1_address), ("referral_code", referral_code)];
445 self.send_post_urlencoded(ENDPOINT_REFERRAL_USE, &fields, auth_token)
446 .await
447 }
448
449 pub async fn send_tx(
455 &self,
456 request: &LighterSendTxRequest,
457 ) -> LighterHttpResult<LighterSendTxResponse> {
458 let fields = request.form_fields();
459 self.send_post_form(ENDPOINT_SEND_TX, &fields).await
460 }
461
462 pub async fn send_tx_batch(
468 &self,
469 request: &LighterSendTxBatchRequest,
470 ) -> LighterHttpResult<LighterSendTxBatchResponse> {
471 let fields = request.form_fields();
472 self.send_post_form(ENDPOINT_SEND_TX_BATCH, &fields).await
473 }
474
475 async fn send_get_request<T, P>(
476 &self,
477 endpoint: &str,
478 params: Option<&P>,
479 ) -> LighterHttpResult<T>
480 where
481 T: DeserializeOwned + LighterResponseCheck,
482 P: Serialize,
483 {
484 self.send_get_request_with_headers(endpoint, params, None)
485 .await
486 }
487
488 async fn send_get_request_with_headers<T, P>(
489 &self,
490 endpoint: &str,
491 params: Option<&P>,
492 headers: Option<HashMap<String, String>>,
493 ) -> LighterHttpResult<T>
494 where
495 T: DeserializeOwned + LighterResponseCheck,
496 P: Serialize,
497 {
498 let url = self.url(endpoint);
499 let rate_limit_keys = Self::rate_limit_keys(endpoint);
500 self.retry_manager
501 .invocation(
502 endpoint,
503 || {
504 let url = url.clone();
505 let rate_limit_keys = rate_limit_keys.clone();
506 let headers = headers.clone();
507
508 async move {
509 let response = self
510 .client
511 .request_with_params(
512 Method::GET,
513 url,
514 params,
515 headers,
516 None,
517 None,
518 Some(rate_limit_keys),
519 )
520 .await?;
521 Self::parse_response(&response)
522 }
523 },
524 should_retry_lighter_http_error,
525 |e| create_lighter_http_timeout_error(e.to_string()),
526 )
527 .retry_delay(&LighterHttpError::retry_after)
528 .execute()
529 .await
530 }
531
532 async fn send_post_form<T>(
535 &self,
536 endpoint: &str,
537 fields: &[(&str, String)],
538 ) -> LighterHttpResult<T>
539 where
540 T: DeserializeOwned + LighterResponseCheck,
541 {
542 let rate_keys = match &self.tx_rate_limiter {
548 Some(limiter) => {
549 await_tx_quota(limiter).await;
550 None
551 }
552 None => Some(Self::rate_limit_keys(endpoint)),
553 };
554
555 let response = self
556 .client
557 .request(
558 Method::POST,
559 self.url(endpoint),
560 None,
561 Some(multipart_headers()),
562 Some(multipart_form_bytes(fields)),
563 None,
564 rate_keys,
565 )
566 .await?;
567
568 Self::parse_response(&response)
569 }
570
571 async fn send_post_urlencoded<T>(
574 &self,
575 endpoint: &str,
576 fields: &[(&str, &str)],
577 auth_token: &str,
578 ) -> LighterHttpResult<T>
579 where
580 T: DeserializeOwned + LighterResponseCheck,
581 {
582 let mut serializer = form_urlencoded::Serializer::new(String::new());
583 serializer.extend_pairs(fields.iter().copied());
584 let body = serializer.finish().into_bytes();
585
586 let headers = HashMap::from([
587 ("Accept".to_string(), "application/json".to_string()),
588 (
589 "Content-Type".to_string(),
590 "application/x-www-form-urlencoded".to_string(),
591 ),
592 (HEADER_AUTHORIZATION.to_string(), auth_token.to_string()),
593 ]);
594
595 let response = self
596 .client
597 .request(
598 Method::POST,
599 self.url(endpoint),
600 None,
601 Some(headers),
602 Some(body),
603 None,
604 Some(Self::rate_limit_keys(endpoint)),
605 )
606 .await?;
607
608 Self::parse_response(&response)
609 }
610
611 fn parse_response<T>(response: &HttpResponse) -> LighterHttpResult<T>
612 where
613 T: DeserializeOwned + LighterResponseCheck,
614 {
615 let retry_after = response.headers.get(HEADER_RETRY_AFTER).and_then(|value| {
616 let now = get_atomic_clock_realtime().get_time_ns().to_datetime_utc();
617 parse_retry_after(value, now)
618 });
619
620 if !response.status.is_success() {
621 let status = response.status.as_u16();
622 let body = String::from_utf8_lossy(&response.body).to_string();
623
624 if status >= 500 {
627 return Err(LighterHttpError::Http {
628 status,
629 body,
630 retry_after,
631 });
632 }
633
634 if status == 429 || status == 405 {
636 return Err(LighterHttpError::RateLimit {
637 message: body,
638 retry_after,
639 });
640 }
641
642 if let Ok(result) = serde_json::from_slice::<LighterResultCode>(&response.body)
643 && result.code != 200
644 {
645 Self::result_code_error(
646 result.code,
647 result.message.as_deref(),
648 "HTTP error",
649 retry_after,
650 )?;
651 }
652
653 return Err(LighterHttpError::Http {
654 status,
655 body,
656 retry_after,
657 });
658 }
659
660 let payload: T = match serde_json::from_slice(&response.body) {
661 Ok(payload) => payload,
662 Err(payload_error) => {
663 if let Ok(result) = serde_json::from_slice::<LighterResultCode>(&response.body) {
664 Self::check_response(&result, retry_after)?;
665 }
666 return Err(payload_error.into());
667 }
668 };
669
670 Self::check_response(&payload, retry_after)?;
671 Ok(payload)
672 }
673
674 fn check_response<T: LighterResponseCheck>(
675 payload: &T,
676 retry_after: Option<Duration>,
677 ) -> LighterHttpResult<()> {
678 Self::result_code_error(
679 payload.response_code(),
680 payload.response_message(),
681 "Lighter request failed",
682 retry_after,
683 )
684 }
685
686 fn result_code_error(
687 code: i32,
688 message: Option<&str>,
689 default_message: &str,
690 retry_after: Option<Duration>,
691 ) -> LighterHttpResult<()> {
692 match code {
693 200 => Ok(()),
694 405 | 429 => Err(LighterHttpError::RateLimit {
695 message: message.unwrap_or("Lighter rate limit exceeded").to_string(),
696 retry_after,
697 }),
698 code => Err(venue_error(code, message, default_message)),
699 }
700 }
701
702 fn url(&self, endpoint: &str) -> String {
703 format!("{}{}", self.base_url, endpoint)
704 }
705
706 fn rate_limit_keys(endpoint: &str) -> Vec<String> {
707 let route = endpoint.strip_prefix(API_V1).unwrap_or(endpoint);
708 vec![
709 LIGHTER_REST_BUCKET.to_string(),
710 format!("lighter:{}", route.trim_start_matches('/')),
711 ]
712 }
713
714 fn default_headers() -> HashMap<String, String> {
715 create_standard_nautilus_headers().into_iter().collect()
716 }
717}
718
719#[derive(Serialize)]
720struct LighterMakerOnlyApiKeysParams {
721 account_index: i64,
722}
723
724fn multipart_headers() -> HashMap<String, String> {
725 HashMap::from([
726 ("Accept".to_string(), "application/json".to_string()),
727 (
728 "Content-Type".to_string(),
729 format!("multipart/form-data; boundary={MULTIPART_BOUNDARY}"),
730 ),
731 ])
732}
733
734fn multipart_form_bytes(fields: &[(&str, String)]) -> Vec<u8> {
735 let mut body = String::new();
736 for (name, value) in fields {
737 body.push_str("--");
738 body.push_str(MULTIPART_BOUNDARY);
739 body.push_str("\r\nContent-Disposition: form-data; name=\"");
740 body.push_str(name);
741 body.push_str("\"\r\n\r\n");
742 body.push_str(value);
743 body.push_str("\r\n");
744 }
745 body.push_str("--");
746 body.push_str(MULTIPART_BOUNDARY);
747 body.push_str("--\r\n");
748 body.into_bytes()
749}
750
751#[derive(Clone, Debug)]
756pub struct LighterHttpClient {
757 pub(crate) inner: Arc<LighterRawHttpClient>,
758 market_registry: Arc<MarketRegistry>,
759 clock: &'static AtomicTime,
760}
761
762impl Default for LighterHttpClient {
763 fn default() -> Self {
764 Self::new(LighterEnvironment::Mainnet, None, 60, None)
765 .expect("failed to create default Lighter HTTP client")
766 }
767}
768
769impl LighterHttpClient {
770 pub fn new(
776 environment: LighterEnvironment,
777 base_url: Option<String>,
778 timeout_secs: u64,
779 proxy_url: Option<String>,
780 ) -> LighterHttpResult<Self> {
781 let raw_client = LighterRawHttpClient::new(environment, base_url, timeout_secs, proxy_url)?;
782 Ok(Self::from_raw(raw_client))
783 }
784
785 #[must_use]
787 pub fn from_raw(raw_client: LighterRawHttpClient) -> Self {
788 Self::from_raw_with_registry(raw_client, Arc::new(MarketRegistry::new()))
789 }
790
791 #[must_use]
793 pub fn from_raw_with_registry(
794 raw_client: LighterRawHttpClient,
795 market_registry: Arc<MarketRegistry>,
796 ) -> Self {
797 Self {
798 inner: Arc::new(raw_client),
799 market_registry,
800 clock: get_atomic_clock_realtime(),
801 }
802 }
803
804 #[must_use]
806 pub fn base_url(&self) -> &str {
807 self.inner.base_url()
808 }
809
810 #[must_use]
812 pub fn environment(&self) -> LighterEnvironment {
813 self.inner.environment()
814 }
815
816 #[must_use]
818 pub fn market_registry(&self) -> Arc<MarketRegistry> {
819 self.market_registry.clone()
820 }
821
822 pub fn set_base_url(&mut self, base_url: &str) {
828 Arc::get_mut(&mut self.inner)
829 .expect("cannot override URL: raw client is shared")
830 .set_base_url(base_url);
831 }
832
833 pub async fn get_order_books(
839 &self,
840 query: &LighterOrderBooksQuery,
841 ) -> LighterHttpResult<LighterOrderBooks> {
842 let response = self.inner.get_order_books(query).await?;
843 register_order_books(&self.market_registry, &response.order_books);
844 Ok(response)
845 }
846
847 pub async fn get_order_book_details(
853 &self,
854 query: &LighterOrderBookDetailsQuery,
855 ) -> LighterHttpResult<LighterOrderBookDetails> {
856 let response = self.inner.get_order_book_details(query).await?;
857 parse_order_book_details_instruments_with_status(
858 &self.market_registry,
859 &response.order_book_details,
860 &response.spot_order_book_details,
861 self.generate_ts_init(),
862 )?;
863 Ok(response)
864 }
865
866 pub async fn get_order_book_orders(
872 &self,
873 query: &LighterOrderBookOrdersQuery,
874 ) -> LighterHttpResult<LighterOrderBookOrders> {
875 self.inner.get_order_book_orders(query).await
876 }
877
878 pub async fn get_recent_trades(
884 &self,
885 query: &LighterRecentTradesQuery,
886 ) -> LighterHttpResult<LighterTrades> {
887 self.inner.get_recent_trades(query).await
888 }
889
890 pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
896 self.inner.get_trades(query).await
897 }
898
899 pub async fn get_candles(
905 &self,
906 query: &LighterCandlesQuery,
907 ) -> LighterHttpResult<LighterCandles> {
908 self.inner.get_candles(query).await
909 }
910
911 pub async fn get_fundings(
917 &self,
918 query: &LighterFundingsQuery,
919 ) -> LighterHttpResult<LighterFundings> {
920 self.inner.get_fundings(query).await
921 }
922
923 pub async fn get_account_detail(
930 &self,
931 account_index: i64,
932 ) -> LighterHttpResult<LighterAccountDetail> {
933 let query = LighterAccountQuery {
934 by: LighterAccountLookup::Index,
935 value: account_index.to_string(),
936 };
937 self.inner
938 .get_account(&query)
939 .await?
940 .accounts
941 .into_iter()
942 .next()
943 .ok_or_else(|| {
944 LighterHttpError::Parse(format!("no account returned for index {account_index}"))
945 })
946 }
947
948 pub async fn get_account_active_orders(
954 &self,
955 query: &LighterAccountActiveOrdersQuery,
956 ) -> LighterHttpResult<LighterOrders> {
957 self.inner.get_account_active_orders(query).await
958 }
959
960 pub async fn get_account_inactive_orders(
966 &self,
967 query: &LighterAccountInactiveOrdersQuery,
968 ) -> LighterHttpResult<LighterOrders> {
969 self.inner.get_account_inactive_orders(query).await
970 }
971
972 pub async fn get_next_nonce(
978 &self,
979 account_index: i64,
980 api_key_index: u8,
981 ) -> LighterHttpResult<LighterNextNonce> {
982 let query = LighterNextNonceQuery {
983 account_index,
984 api_key_index,
985 };
986 self.inner.get_next_nonce(&query).await
987 }
988
989 pub async fn get_tx(&self, tx_hash: impl Into<String>) -> LighterHttpResult<LighterTx> {
995 self.inner
996 .get_tx(&LighterTxQuery {
997 by: LighterTxLookup::Hash,
998 value: tx_hash.into(),
999 })
1000 .await
1001 }
1002
1003 pub async fn get_maker_only_api_keys(
1012 &self,
1013 account_index: i64,
1014 auth_token: impl Into<SecretString>,
1015 ) -> LighterHttpResult<LighterMakerOnlyApiKeys> {
1016 let query = Zeroizing::new(LighterMakerOnlyApiKeysQuery {
1017 authorization: Some(auth_token.into()),
1018 auth: None,
1019 account_index,
1020 });
1021 self.inner.get_maker_only_api_keys(&query).await
1022 }
1023
1024 pub async fn use_referral(
1030 &self,
1031 l1_address: &str,
1032 referral_code: &str,
1033 auth_token: &SecretString,
1034 ) -> LighterHttpResult<LighterResultCode> {
1035 self.inner
1036 .use_referral(l1_address, referral_code, auth_token.expose_secret())
1037 .await
1038 }
1039
1040 pub async fn send_tx(
1046 &self,
1047 request: &LighterSendTxRequest,
1048 ) -> LighterHttpResult<LighterSendTxResponse> {
1049 self.inner.send_tx(request).await
1050 }
1051
1052 pub async fn send_tx_batch(
1058 &self,
1059 request: &LighterSendTxBatchRequest,
1060 ) -> LighterHttpResult<LighterSendTxBatchResponse> {
1061 self.inner.send_tx_batch(request).await
1062 }
1063
1064 pub async fn request_recent_trades(
1071 &self,
1072 instrument: &InstrumentAny,
1073 limit: u16,
1074 ) -> LighterHttpResult<Vec<TradeTick>> {
1075 let market_id = self.market_index(instrument)?;
1076 let query = LighterRecentTradesQuery { market_id, limit };
1077 let response = self.inner.get_recent_trades(&query).await?;
1078 self.parse_trade_ticks(&response.trades, instrument)
1079 }
1080
1081 pub async fn request_trades(
1090 &self,
1091 instrument: &InstrumentAny,
1092 query: LighterTradesQuery,
1093 ) -> LighterHttpResult<Vec<TradeTick>> {
1094 let mut query = Zeroizing::new(query);
1095 if query.market_id.is_none() {
1096 query.market_id = Some(self.market_index(instrument)?);
1097 }
1098 let response = self.inner.get_trades(&query).await?;
1099 self.parse_trade_ticks(&response.trades, instrument)
1100 }
1101
1102 pub async fn request_bars(
1110 &self,
1111 instrument: &InstrumentAny,
1112 bar_type: BarType,
1113 start: Option<Timestamp>,
1114 end: Option<Timestamp>,
1115 limit: Option<u32>,
1116 ) -> LighterHttpResult<Vec<Bar>> {
1117 let market_id = self.market_index(instrument)?;
1118 let resolution = LighterCandleResolution::try_from(&bar_type)?;
1119 let interval_ms = resolution.interval_millis();
1120 let now = Timestamp::now();
1121
1122 if let (Some(start), Some(end)) = (start, end)
1123 && start >= end
1124 {
1125 return Err(LighterHttpError::Parse(format!(
1126 "invalid bar request range: start={start}, end={end}",
1127 )));
1128 }
1129
1130 let end = end.unwrap_or(now).min(now);
1131
1132 if let Some(start) = start
1133 && start >= end
1134 {
1135 return Ok(Vec::new());
1136 }
1137
1138 let requested_limit = limit.filter(|n| *n > 0).map(|n| n as usize);
1139 let target_limit = requested_limit.unwrap_or(DEFAULT_BARS_LIMIT);
1140 let start_was_unspecified = start.is_none();
1141 let end_ms = end.as_millisecond().max(0);
1142 let now_ms = now.as_millisecond();
1143
1144 if end_ms == 0 {
1145 return Ok(Vec::new());
1146 }
1147
1148 let start_ms = start.map_or_else(
1149 || {
1150 let lookback_bars = target_limit.saturating_add(1);
1151 let lookback_bars = i64::try_from(lookback_bars).unwrap_or(i64::MAX);
1152 let lookback_ms = interval_ms.saturating_mul(lookback_bars);
1153 end_ms.saturating_sub(lookback_ms)
1154 },
1155 |dt| dt.as_millisecond().max(0),
1156 );
1157
1158 if start_ms >= end_ms {
1159 return Ok(Vec::new());
1160 }
1161
1162 let mut bars = Vec::new();
1163 let mut cursor_ms = start_ms;
1164 let mut pages = 0_usize;
1165 let page_span_ms = interval_ms.saturating_mul(i64::from(LIGHTER_CANDLES_MAX_LIMIT));
1166
1167 while cursor_ms < end_ms && pages < MAX_BAR_REQUEST_PAGES {
1168 if !start_was_unspecified
1169 && let Some(limit) = requested_limit
1170 && bars.len() >= limit
1171 {
1172 break;
1173 }
1174
1175 let window_end_ms = cursor_ms.saturating_add(page_span_ms).min(end_ms);
1176 if window_end_ms <= cursor_ms {
1177 break;
1178 }
1179
1180 let query = LighterCandlesQuery {
1181 market_id,
1182 resolution,
1183 start_timestamp: cursor_ms,
1184 end_timestamp: window_end_ms,
1185 count_back: i64::from(LIGHTER_CANDLES_MAX_LIMIT),
1186 set_timestamp_to_end: Some(false),
1187 };
1188 let response = self.get_candles(&query).await?;
1189 let mut page = self.parse_bars(&response.candles, instrument, bar_type)?;
1190
1191 page.sort_by_key(|bar| bar.ts_event);
1192 for bar in page {
1193 let bar_start_ms = i64::try_from(bar.ts_event.as_u64() / 1_000_000)
1194 .map_err(|e| LighterHttpError::Parse(e.to_string()))?;
1195
1196 if bar_start_ms < cursor_ms
1197 || bar_start_ms >= end_ms
1198 || bar_start_ms.saturating_add(interval_ms) > now_ms
1199 {
1200 continue;
1201 }
1202
1203 if bars
1204 .last()
1205 .is_some_and(|last: &Bar| last.ts_event == bar.ts_event)
1206 {
1207 continue;
1208 }
1209 bars.push(bar);
1210
1211 if !start_was_unspecified
1212 && let Some(limit) = requested_limit
1213 && bars.len() >= limit
1214 {
1215 break;
1216 }
1217 }
1218
1219 cursor_ms = window_end_ms;
1220 pages += 1;
1221 }
1222
1223 let limit_satisfied =
1224 !start_was_unspecified && requested_limit.is_some_and(|limit| bars.len() >= limit);
1225 if pages >= MAX_BAR_REQUEST_PAGES && cursor_ms < end_ms && !limit_satisfied {
1226 return Err(LighterHttpError::HistoryIncomplete {
1227 data_type: "bar",
1228 pages,
1229 });
1230 }
1231
1232 if start_was_unspecified && bars.len() > target_limit {
1233 bars = bars.split_off(bars.len() - target_limit);
1234 }
1235
1236 Ok(bars)
1237 }
1238
1239 pub async fn request_funding_rates(
1251 &self,
1252 instrument: &InstrumentAny,
1253 start: Option<Timestamp>,
1254 end: Option<Timestamp>,
1255 limit: Option<usize>,
1256 ) -> LighterHttpResult<Vec<FundingRateUpdate>> {
1257 if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
1258 return Err(LighterHttpError::Parse(format!(
1259 "funding rates are only available for perpetual instruments: {}",
1260 instrument.id()
1261 )));
1262 }
1263
1264 let market_id = self.market_index(instrument)?;
1265 let resolution = LighterFundingResolution::OneHour;
1266 let interval_ms = resolution.interval_millis();
1267 let now = Timestamp::now();
1268
1269 if let (Some(start), Some(end)) = (start, end)
1270 && start >= end
1271 {
1272 return Err(LighterHttpError::Parse(format!(
1273 "invalid funding request range: start={start}, end={end}",
1274 )));
1275 }
1276
1277 let end = end.unwrap_or(now).min(now);
1278
1279 if let Some(start) = start
1280 && start >= end
1281 {
1282 return Ok(Vec::new());
1283 }
1284
1285 let requested_limit = limit.filter(|n| *n > 0);
1286 let target_limit = requested_limit.unwrap_or(DEFAULT_FUNDING_RATES_LIMIT);
1287 let start_was_unspecified = start.is_none();
1288 let end_ms = end.as_millisecond().max(0);
1289
1290 if end_ms == 0 {
1291 return Ok(Vec::new());
1292 }
1293
1294 let start_ms = start.map_or_else(
1295 || {
1296 let lookback_rows = target_limit.saturating_add(1);
1297 let lookback_rows = i64::try_from(lookback_rows).unwrap_or(i64::MAX);
1298 let lookback_ms = interval_ms.saturating_mul(lookback_rows);
1299 end_ms.saturating_sub(lookback_ms)
1300 },
1301 |dt| dt.as_millisecond().max(0),
1302 );
1303
1304 if start_ms >= end_ms {
1305 return Ok(Vec::new());
1306 }
1307
1308 let ts_init = self.generate_ts_init();
1309 let interval = Some(resolution.interval_minutes());
1310 let mut funding_rates = Vec::new();
1311 let mut cursor_ms = start_ms;
1312 let mut pages = 0_usize;
1313 let page_span_ms =
1316 interval_ms.saturating_mul(i64::from(LIGHTER_FUNDINGS_MAX_LIMIT.saturating_sub(1)));
1317
1318 while cursor_ms < end_ms && pages < MAX_FUNDING_REQUEST_PAGES {
1319 if !start_was_unspecified
1320 && let Some(limit) = requested_limit
1321 && funding_rates.len() >= limit
1322 {
1323 break;
1324 }
1325
1326 let window_end_ms = cursor_ms.saturating_add(page_span_ms).min(end_ms);
1327 if window_end_ms <= cursor_ms {
1328 break;
1329 }
1330
1331 let query = LighterFundingsQuery {
1332 market_id,
1333 resolution,
1334 start_timestamp: cursor_ms,
1335 end_timestamp: window_end_ms,
1336 count_back: i64::from(LIGHTER_FUNDINGS_MAX_LIMIT),
1337 };
1338 let response = self.get_fundings(&query).await?;
1339
1340 let mut page = Vec::with_capacity(response.fundings.len());
1341 for funding in &response.fundings {
1342 let update = parse_funding_rate_update(funding, instrument.id(), interval, ts_init)
1343 .map_err(LighterHttpError::from)?;
1344 let timestamp_ms = i64::try_from(update.ts_event.as_u64() / 1_000_000)
1345 .map_err(|e| LighterHttpError::Parse(e.to_string()))?;
1346
1347 if timestamp_ms < cursor_ms || timestamp_ms > end_ms {
1348 continue;
1349 }
1350 page.push(update);
1351 }
1352
1353 page.sort_by_key(|rate| rate.ts_event);
1354 for update in page {
1355 if funding_rates
1356 .last()
1357 .is_some_and(|last: &FundingRateUpdate| last.ts_event == update.ts_event)
1358 {
1359 continue;
1360 }
1361 funding_rates.push(update);
1362
1363 if !start_was_unspecified
1364 && let Some(limit) = requested_limit
1365 && funding_rates.len() >= limit
1366 {
1367 break;
1368 }
1369 }
1370
1371 cursor_ms = window_end_ms;
1372 pages += 1;
1373 }
1374
1375 let limit_satisfied = !start_was_unspecified
1376 && requested_limit.is_some_and(|limit| funding_rates.len() >= limit);
1377 if pages >= MAX_FUNDING_REQUEST_PAGES && cursor_ms < end_ms && !limit_satisfied {
1378 return Err(LighterHttpError::HistoryIncomplete {
1379 data_type: "funding rate",
1380 pages,
1381 });
1382 }
1383
1384 if start_was_unspecified && funding_rates.len() > target_limit {
1385 funding_rates = funding_rates.split_off(funding_rates.len() - target_limit);
1386 }
1387
1388 Ok(funding_rates)
1389 }
1390
1391 pub async fn request_instruments(&self) -> LighterHttpResult<Vec<InstrumentAny>> {
1399 self.request_instruments_for_query(&LighterOrderBookDetailsQuery::default())
1400 .await
1401 }
1402
1403 pub async fn request_instruments_with_status(
1409 &self,
1410 ) -> LighterHttpResult<Vec<(InstrumentAny, LighterMarketStatus)>> {
1411 self.request_instruments_with_status_for_query(&LighterOrderBookDetailsQuery::default())
1412 .await
1413 }
1414
1415 pub async fn request_instrument(
1422 &self,
1423 instrument_id: InstrumentId,
1424 ) -> LighterHttpResult<InstrumentAny> {
1425 self.request_instrument_with_status(instrument_id)
1426 .await
1427 .map(|(instrument, _)| instrument)
1428 }
1429
1430 pub async fn request_instrument_with_status(
1437 &self,
1438 instrument_id: InstrumentId,
1439 ) -> LighterHttpResult<(InstrumentAny, LighterMarketStatus)> {
1440 let query = LighterOrderBookDetailsQuery {
1441 market_id: self.market_registry.market_index(&instrument_id),
1442 filter: None,
1443 };
1444 let instruments = self
1445 .request_instruments_with_status_for_query(&query)
1446 .await?;
1447
1448 instruments
1449 .into_iter()
1450 .find(|(instrument, _)| instrument.id() == instrument_id)
1451 .ok_or_else(|| LighterHttpError::Parse(format!("instrument {instrument_id} not found")))
1452 }
1453
1454 pub async fn request_order_book_snapshot(
1461 &self,
1462 instrument: &InstrumentAny,
1463 limit: u16,
1464 ) -> LighterHttpResult<OrderBookDeltas> {
1465 let query = LighterOrderBookOrdersQuery {
1466 market_id: self.market_index(instrument)?,
1467 limit,
1468 };
1469 let snapshot = self.inner.get_order_book_orders(&query).await?;
1470 let ts_init = self.generate_ts_init();
1471
1472 parse_order_book_snapshot(
1473 &snapshot,
1474 instrument.id(),
1475 instrument.price_precision(),
1476 instrument.size_precision(),
1477 ts_init,
1478 ts_init,
1479 )
1480 .map_err(LighterHttpError::from)
1481 }
1482
1483 async fn request_instruments_for_query(
1484 &self,
1485 query: &LighterOrderBookDetailsQuery,
1486 ) -> LighterHttpResult<Vec<InstrumentAny>> {
1487 self.request_instruments_with_status_for_query(query)
1488 .await
1489 .map(|instruments| {
1490 instruments
1491 .into_iter()
1492 .map(|(instrument, _)| instrument)
1493 .collect()
1494 })
1495 }
1496
1497 async fn request_instruments_with_status_for_query(
1498 &self,
1499 query: &LighterOrderBookDetailsQuery,
1500 ) -> LighterHttpResult<Vec<(InstrumentAny, LighterMarketStatus)>> {
1501 let response = self.inner.get_order_book_details(query).await?;
1502 let ts_init = self.generate_ts_init();
1503 parse_order_book_details_instruments_with_status(
1504 &self.market_registry,
1505 &response.order_book_details,
1506 &response.spot_order_book_details,
1507 ts_init,
1508 )
1509 .map_err(LighterHttpError::from)
1510 }
1511
1512 fn parse_trade_ticks(
1513 &self,
1514 trades: &[LighterTrade],
1515 instrument: &InstrumentAny,
1516 ) -> LighterHttpResult<Vec<TradeTick>> {
1517 let ts_init = self.generate_ts_init();
1518 trades
1519 .iter()
1520 .map(|trade| parse_trade_tick(trade, instrument, ts_init).map_err(Into::into))
1521 .collect()
1522 }
1523
1524 fn parse_bars(
1525 &self,
1526 candles: &[LighterCandle],
1527 instrument: &InstrumentAny,
1528 bar_type: BarType,
1529 ) -> LighterHttpResult<Vec<Bar>> {
1530 let ts_init = self.generate_ts_init();
1531 candles
1532 .iter()
1533 .filter_map(|candle| {
1534 let has_positive_ohlc = candle.open > Decimal::ZERO
1535 && candle.high > Decimal::ZERO
1536 && candle.low > Decimal::ZERO
1537 && candle.close > Decimal::ZERO;
1538
1539 if !has_positive_ohlc {
1540 log::warn!(
1541 "Skipping Lighter candle at timestamp {} with non-positive OHLC values",
1542 candle.timestamp,
1543 );
1544 return None;
1545 }
1546
1547 Some(parse_candle_bar(candle, bar_type, instrument, ts_init).map_err(Into::into))
1548 })
1549 .collect()
1550 }
1551
1552 fn market_index(&self, instrument: &InstrumentAny) -> LighterHttpResult<i64> {
1553 self.market_registry
1554 .market_index(&instrument.id())
1555 .ok_or_else(|| {
1556 LighterHttpError::Parse(format!(
1557 "market index not registered for instrument {}",
1558 instrument.id()
1559 ))
1560 })
1561 }
1562
1563 fn generate_ts_init(&self) -> UnixNanos {
1564 self.clock.get_time_ns()
1565 }
1566}
1567
1568fn venue_error(code: i32, message: Option<&str>, default_message: &str) -> LighterHttpError {
1569 LighterHttpError::Venue {
1570 code: i64::from(code),
1571 message: message.unwrap_or(default_message).to_string(),
1572 }
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use nautilus_testkit::http::assert_http_redirect_rejected;
1578 use rstest::rstest;
1579
1580 use super::*;
1581
1582 #[tokio::test]
1583 async fn test_authenticated_client_rejects_redirects() {
1584 let client = LighterRawHttpClient::new(LighterEnvironment::Testnet, None, 3, None)
1585 .unwrap()
1586 .client;
1587 assert_http_redirect_rejected(|url| async move {
1588 client
1589 .get(url, None, None, Some(3), None)
1590 .await
1591 .unwrap()
1592 .status
1593 .as_u16()
1594 })
1595 .await;
1596 }
1597
1598 #[rstest]
1599 #[case(ENDPOINT_TRADES, "lighter:trades")]
1600 #[case(ENDPOINT_SEND_TX, "lighter:sendTx")]
1601 #[case(ENDPOINT_SEND_TX_BATCH, "lighter:sendTxBatch")]
1602 fn test_rate_limit_keys_uses_rest_bucket_and_route(
1603 #[case] endpoint: &str,
1604 #[case] route: &str,
1605 ) {
1606 assert_eq!(
1609 LighterRawHttpClient::rate_limit_keys(endpoint),
1610 vec![LIGHTER_REST_BUCKET.to_string(), route.to_string()],
1611 );
1612 }
1613}