1use std::{collections::HashMap, sync::Arc};
19
20use jiff::Timestamp;
21use nautilus_core::{
22 AtomicTime, UnixNanos, consts::NAUTILUS_USER_AGENT, 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::{HttpClient, HttpResponse, Method, USER_AGENT},
31 ratelimiter::quota::Quota,
32 retry::{RetryManager, create_http_retry_manager},
33};
34use rust_decimal::Decimal;
35use serde::{Serialize, de::DeserializeOwned};
36use url::form_urlencoded;
37
38use crate::{
39 common::{
40 enums::{
41 LighterCandleResolution, LighterEnvironment, LighterFundingResolution,
42 LighterMarketStatus,
43 },
44 rate_limit::{
45 LIGHTER_REST_BUCKET, LIGHTER_REST_QUOTA, LighterTxRateLimiter, await_tx_quota,
46 },
47 symbol::MarketRegistry,
48 urls::lighter_http_base_url,
49 },
50 http::{
51 error::{
52 LighterHttpError, LighterHttpResult, create_lighter_http_timeout_error,
53 should_retry_lighter_http_error,
54 },
55 models::{
56 LighterAccountDetail, LighterAccountsResponse, LighterCandle, LighterCandles,
57 LighterFundings, LighterMakerOnlyApiKeys, LighterNextNonce, LighterOrderBookDetails,
58 LighterOrderBookOrders, LighterOrderBooks, LighterOrders, LighterResultCode,
59 LighterSendTxBatchRequest, LighterSendTxBatchResponse, LighterSendTxRequest,
60 LighterSendTxResponse, LighterTrade, LighterTrades, LighterTx,
61 },
62 parse::{
63 parse_candle_bar, parse_funding_rate_update,
64 parse_order_book_details_instruments_with_status, parse_order_book_snapshot,
65 parse_trade_tick, register_order_books,
66 },
67 query::{
68 LighterAccountActiveOrdersQuery, LighterAccountInactiveOrdersQuery,
69 LighterAccountLookup, LighterAccountQuery, LighterCandlesQuery, LighterFundingsQuery,
70 LighterMakerOnlyApiKeysQuery, LighterNextNonceQuery, LighterOrderBookDetailsQuery,
71 LighterOrderBookOrdersQuery, LighterOrderBooksQuery, LighterRecentTradesQuery,
72 LighterTradesQuery, LighterTxLookup, LighterTxQuery,
73 },
74 },
75};
76
77const API_V1: &str = "/api/v1";
78const ENDPOINT_ACCOUNT: &str = "/api/v1/account";
79const ENDPOINT_ACCOUNT_ACTIVE_ORDERS: &str = "/api/v1/accountActiveOrders";
80const ENDPOINT_ACCOUNT_INACTIVE_ORDERS: &str = "/api/v1/accountInactiveOrders";
81const ENDPOINT_CANDLES: &str = "/api/v1/candles";
82const ENDPOINT_FUNDINGS: &str = "/api/v1/fundings";
83const ENDPOINT_MAKER_ONLY_API_KEYS: &str = "/api/v1/getMakerOnlyApiKeys";
84const ENDPOINT_NEXT_NONCE: &str = "/api/v1/nextNonce";
85const ENDPOINT_ORDER_BOOK_DETAILS: &str = "/api/v1/orderBookDetails";
86const ENDPOINT_ORDER_BOOK_ORDERS: &str = "/api/v1/orderBookOrders";
87const ENDPOINT_ORDER_BOOKS: &str = "/api/v1/orderBooks";
88const ENDPOINT_RECENT_TRADES: &str = "/api/v1/recentTrades";
89const ENDPOINT_REFERRAL_USE: &str = "/api/v1/referral/use";
90const ENDPOINT_SEND_TX: &str = "/api/v1/sendTx";
91const ENDPOINT_SEND_TX_BATCH: &str = "/api/v1/sendTxBatch";
92const ENDPOINT_TRADES: &str = "/api/v1/trades";
93const ENDPOINT_TX: &str = "/api/v1/tx";
94const HEADER_AUTHORIZATION: &str = "authorization";
95const MULTIPART_BOUNDARY: &str = "nautilus-lighter-form-boundary";
96
97pub const LIGHTER_REST_PAGE_SIZE: u16 = 100;
102pub const LIGHTER_CANDLES_MAX_LIMIT: u16 = 500;
103
104pub const LIGHTER_FUNDINGS_MAX_LIMIT: u16 = 100;
106
107const DEFAULT_BARS_LIMIT: usize = LIGHTER_CANDLES_MAX_LIMIT as usize;
108const DEFAULT_FUNDING_RATES_LIMIT: usize = 100;
109const MAX_BAR_REQUEST_PAGES: usize = 500;
110const MAX_FUNDING_REQUEST_PAGES: usize = 500;
111
112trait LighterResponseCheck {
113 fn response_code(&self) -> i32;
114 fn response_message(&self) -> Option<&str>;
115}
116
117macro_rules! impl_lighter_response_check {
118 ($($ty:ty),+ $(,)?) => {
119 $(
120 impl LighterResponseCheck for $ty {
121 fn response_code(&self) -> i32 {
122 self.code
123 }
124
125 fn response_message(&self) -> Option<&str> {
126 self.message.as_deref()
127 }
128 }
129 )+
130 };
131}
132
133impl_lighter_response_check!(
134 LighterAccountsResponse,
135 LighterCandles,
136 LighterFundings,
137 LighterMakerOnlyApiKeys,
138 LighterNextNonce,
139 LighterOrderBookDetails,
140 LighterOrderBookOrders,
141 LighterOrderBooks,
142 LighterOrders,
143 LighterResultCode,
144 LighterSendTxBatchResponse,
145 LighterSendTxResponse,
146 LighterTrades,
147 LighterTx,
148);
149
150#[derive(Clone, Debug)]
155pub struct LighterRawHttpClient {
156 base_url: String,
157 environment: LighterEnvironment,
158 client: HttpClient,
159 retry_manager: RetryManager<LighterHttpError>,
160 tx_rate_limiter: Option<Arc<LighterTxRateLimiter>>,
161}
162
163impl Default for LighterRawHttpClient {
164 fn default() -> Self {
165 Self::new(LighterEnvironment::Mainnet, None, 60, None)
166 .expect("failed to create default Lighter raw HTTP client")
167 }
168}
169
170impl LighterRawHttpClient {
171 pub fn new(
177 environment: LighterEnvironment,
178 base_url: Option<String>,
179 timeout_secs: u64,
180 proxy_url: Option<String>,
181 ) -> LighterHttpResult<Self> {
182 Self::new_with_quotas(
183 environment,
184 base_url,
185 timeout_secs,
186 proxy_url,
187 *LIGHTER_REST_QUOTA,
188 None,
189 )
190 }
191
192 pub fn new_with_quotas(
206 environment: LighterEnvironment,
207 base_url: Option<String>,
208 timeout_secs: u64,
209 proxy_url: Option<String>,
210 default_quota: Quota,
211 tx_rate_limiter: Option<Arc<LighterTxRateLimiter>>,
212 ) -> LighterHttpResult<Self> {
213 let base_url = base_url
214 .unwrap_or_else(|| lighter_http_base_url(environment).to_string())
215 .trim_end_matches('/')
216 .to_string();
217
218 Ok(Self {
219 base_url,
220 environment,
221 client: HttpClient::builder()
222 .headers(Self::default_headers())
223 .default_quota(default_quota)
224 .timeout_secs(timeout_secs)
225 .maybe_proxy_url(proxy_url)
226 .build()?,
227 retry_manager: create_http_retry_manager(),
228 tx_rate_limiter,
229 })
230 }
231
232 #[must_use]
234 pub fn base_url(&self) -> &str {
235 self.base_url.as_str()
236 }
237
238 #[must_use]
240 pub const fn environment(&self) -> LighterEnvironment {
241 self.environment
242 }
243
244 pub fn set_base_url(&mut self, base_url: &str) {
246 self.base_url = base_url.trim_end_matches('/').to_string();
247 }
248
249 pub fn set_retry_manager(&mut self, retry_manager: RetryManager<LighterHttpError>) {
252 self.retry_manager = retry_manager;
253 }
254
255 pub async fn get_order_books(
261 &self,
262 query: &LighterOrderBooksQuery,
263 ) -> LighterHttpResult<LighterOrderBooks> {
264 self.send_get_request(ENDPOINT_ORDER_BOOKS, Some(query))
265 .await
266 }
267
268 pub async fn get_order_book_details(
274 &self,
275 query: &LighterOrderBookDetailsQuery,
276 ) -> LighterHttpResult<LighterOrderBookDetails> {
277 self.send_get_request(ENDPOINT_ORDER_BOOK_DETAILS, Some(query))
278 .await
279 }
280
281 pub async fn get_order_book_orders(
287 &self,
288 query: &LighterOrderBookOrdersQuery,
289 ) -> LighterHttpResult<LighterOrderBookOrders> {
290 self.send_get_request(ENDPOINT_ORDER_BOOK_ORDERS, Some(query))
291 .await
292 }
293
294 pub async fn get_recent_trades(
300 &self,
301 query: &LighterRecentTradesQuery,
302 ) -> LighterHttpResult<LighterTrades> {
303 self.send_get_request(ENDPOINT_RECENT_TRADES, Some(query))
304 .await
305 }
306
307 pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
313 self.send_get_request(ENDPOINT_TRADES, Some(query)).await
314 }
315
316 pub async fn get_candles(
322 &self,
323 query: &LighterCandlesQuery,
324 ) -> LighterHttpResult<LighterCandles> {
325 self.send_get_request(ENDPOINT_CANDLES, Some(query)).await
326 }
327
328 pub async fn get_fundings(
334 &self,
335 query: &LighterFundingsQuery,
336 ) -> LighterHttpResult<LighterFundings> {
337 self.send_get_request(ENDPOINT_FUNDINGS, Some(query)).await
338 }
339
340 pub async fn get_account(
346 &self,
347 query: &LighterAccountQuery,
348 ) -> LighterHttpResult<LighterAccountsResponse> {
349 self.send_get_request(ENDPOINT_ACCOUNT, Some(query)).await
350 }
351
352 pub async fn get_account_active_orders(
358 &self,
359 query: &LighterAccountActiveOrdersQuery,
360 ) -> LighterHttpResult<LighterOrders> {
361 self.send_get_request(ENDPOINT_ACCOUNT_ACTIVE_ORDERS, Some(query))
362 .await
363 }
364
365 pub async fn get_account_inactive_orders(
371 &self,
372 query: &LighterAccountInactiveOrdersQuery,
373 ) -> LighterHttpResult<LighterOrders> {
374 self.send_get_request(ENDPOINT_ACCOUNT_INACTIVE_ORDERS, Some(query))
375 .await
376 }
377
378 pub async fn get_next_nonce(
384 &self,
385 query: &LighterNextNonceQuery,
386 ) -> LighterHttpResult<LighterNextNonce> {
387 self.send_get_request(ENDPOINT_NEXT_NONCE, Some(query))
388 .await
389 }
390
391 pub async fn get_tx(&self, query: &LighterTxQuery) -> LighterHttpResult<LighterTx> {
397 self.send_get_request(ENDPOINT_TX, Some(query)).await
398 }
399
400 pub async fn get_maker_only_api_keys(
406 &self,
407 query: &LighterMakerOnlyApiKeysQuery,
408 ) -> LighterHttpResult<LighterMakerOnlyApiKeys> {
409 let params = LighterMakerOnlyApiKeysParams {
410 account_index: query.account_index,
411 };
412 let headers = query
413 .authorization
414 .as_ref()
415 .or(query.auth.as_ref())
416 .map(|auth| HashMap::from([(HEADER_AUTHORIZATION.to_string(), auth.clone())]));
417 self.send_get_request_with_headers(ENDPOINT_MAKER_ONLY_API_KEYS, Some(¶ms), headers)
418 .await
419 }
420
421 pub async fn use_referral(
427 &self,
428 l1_address: &str,
429 referral_code: &str,
430 auth_token: &str,
431 ) -> LighterHttpResult<LighterResultCode> {
432 let fields = [("l1_address", l1_address), ("referral_code", referral_code)];
433 self.send_post_urlencoded(ENDPOINT_REFERRAL_USE, &fields, auth_token)
434 .await
435 }
436
437 pub async fn send_tx(
443 &self,
444 request: &LighterSendTxRequest,
445 ) -> LighterHttpResult<LighterSendTxResponse> {
446 let fields = request.form_fields();
447 self.send_post_form(ENDPOINT_SEND_TX, &fields).await
448 }
449
450 pub async fn send_tx_batch(
456 &self,
457 request: &LighterSendTxBatchRequest,
458 ) -> LighterHttpResult<LighterSendTxBatchResponse> {
459 let fields = request.form_fields();
460 self.send_post_form(ENDPOINT_SEND_TX_BATCH, &fields).await
461 }
462
463 async fn send_get_request<T, P>(
464 &self,
465 endpoint: &str,
466 params: Option<&P>,
467 ) -> LighterHttpResult<T>
468 where
469 T: DeserializeOwned + LighterResponseCheck,
470 P: Serialize,
471 {
472 self.send_get_request_with_headers(endpoint, params, None)
473 .await
474 }
475
476 async fn send_get_request_with_headers<T, P>(
477 &self,
478 endpoint: &str,
479 params: Option<&P>,
480 headers: Option<HashMap<String, String>>,
481 ) -> LighterHttpResult<T>
482 where
483 T: DeserializeOwned + LighterResponseCheck,
484 P: Serialize,
485 {
486 let url = self.url(endpoint);
487 let rate_limit_keys = Self::rate_limit_keys(endpoint);
488 self.retry_manager
489 .execute_with_retry(
490 endpoint,
491 || {
492 let url = url.clone();
493 let rate_limit_keys = rate_limit_keys.clone();
494 let headers = headers.clone();
495
496 async move {
497 let response = self
498 .client
499 .request_with_params(
500 Method::GET,
501 url,
502 params,
503 headers,
504 None,
505 None,
506 Some(rate_limit_keys),
507 )
508 .await?;
509 Self::parse_response(&response)
510 }
511 },
512 should_retry_lighter_http_error,
513 |e| create_lighter_http_timeout_error(e.to_string()),
514 )
515 .await
516 }
517
518 async fn send_post_form<T>(
521 &self,
522 endpoint: &str,
523 fields: &[(&str, String)],
524 ) -> LighterHttpResult<T>
525 where
526 T: DeserializeOwned + LighterResponseCheck,
527 {
528 let rate_keys = match &self.tx_rate_limiter {
534 Some(limiter) => {
535 await_tx_quota(limiter).await;
536 None
537 }
538 None => Some(Self::rate_limit_keys(endpoint)),
539 };
540
541 let response = self
542 .client
543 .request(
544 Method::POST,
545 self.url(endpoint),
546 None,
547 Some(multipart_headers()),
548 Some(multipart_form_bytes(fields)),
549 None,
550 rate_keys,
551 )
552 .await?;
553
554 Self::parse_response(&response)
555 }
556
557 async fn send_post_urlencoded<T>(
560 &self,
561 endpoint: &str,
562 fields: &[(&str, &str)],
563 auth_token: &str,
564 ) -> LighterHttpResult<T>
565 where
566 T: DeserializeOwned + LighterResponseCheck,
567 {
568 let mut serializer = form_urlencoded::Serializer::new(String::new());
569 serializer.extend_pairs(fields.iter().copied());
570 let body = serializer.finish().into_bytes();
571
572 let headers = HashMap::from([
573 ("Accept".to_string(), "application/json".to_string()),
574 (
575 "Content-Type".to_string(),
576 "application/x-www-form-urlencoded".to_string(),
577 ),
578 (HEADER_AUTHORIZATION.to_string(), auth_token.to_string()),
579 ]);
580
581 let response = self
582 .client
583 .request(
584 Method::POST,
585 self.url(endpoint),
586 None,
587 Some(headers),
588 Some(body),
589 None,
590 Some(Self::rate_limit_keys(endpoint)),
591 )
592 .await?;
593
594 Self::parse_response(&response)
595 }
596
597 fn parse_response<T>(response: &HttpResponse) -> LighterHttpResult<T>
598 where
599 T: DeserializeOwned + LighterResponseCheck,
600 {
601 if !response.status.is_success() {
602 let status = response.status.as_u16();
603 let body = String::from_utf8_lossy(&response.body).to_string();
604
605 if status >= 500 {
608 return Err(LighterHttpError::Http { status, body });
609 }
610
611 if status == 429 || status == 405 {
613 return Err(LighterHttpError::RateLimit(body));
614 }
615
616 if let Ok(result) = serde_json::from_slice::<LighterResultCode>(&response.body)
617 && result.code != 200
618 {
619 Self::result_code_error(result.code, result.message.as_deref(), "HTTP error")?;
620 }
621
622 return Err(LighterHttpError::Http { status, body });
623 }
624
625 let payload: T = match serde_json::from_slice(&response.body) {
626 Ok(payload) => payload,
627 Err(payload_error) => {
628 if let Ok(result) = serde_json::from_slice::<LighterResultCode>(&response.body) {
629 Self::check_response(&result)?;
630 }
631 return Err(payload_error.into());
632 }
633 };
634 Self::check_response(&payload)?;
635 Ok(payload)
636 }
637
638 fn check_response<T: LighterResponseCheck>(payload: &T) -> LighterHttpResult<()> {
639 Self::result_code_error(
640 payload.response_code(),
641 payload.response_message(),
642 "Lighter request failed",
643 )
644 }
645
646 fn result_code_error(
647 code: i32,
648 message: Option<&str>,
649 default_message: &str,
650 ) -> LighterHttpResult<()> {
651 match code {
652 200 => Ok(()),
653 405 | 429 => Err(LighterHttpError::RateLimit(
654 message.unwrap_or("Lighter rate limit exceeded").to_string(),
655 )),
656 code => Err(venue_error(code, message, default_message)),
657 }
658 }
659
660 fn url(&self, endpoint: &str) -> String {
661 format!("{}{}", self.base_url, endpoint)
662 }
663
664 fn rate_limit_keys(endpoint: &str) -> Vec<String> {
665 let route = endpoint.strip_prefix(API_V1).unwrap_or(endpoint);
666 vec![
667 LIGHTER_REST_BUCKET.to_string(),
668 format!("lighter:{}", route.trim_start_matches('/')),
669 ]
670 }
671
672 fn default_headers() -> HashMap<String, String> {
673 HashMap::from([(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())])
674 }
675}
676
677#[derive(Serialize)]
678struct LighterMakerOnlyApiKeysParams {
679 account_index: i64,
680}
681
682fn multipart_headers() -> HashMap<String, String> {
683 HashMap::from([
684 ("Accept".to_string(), "application/json".to_string()),
685 (
686 "Content-Type".to_string(),
687 format!("multipart/form-data; boundary={MULTIPART_BOUNDARY}"),
688 ),
689 ])
690}
691
692fn multipart_form_bytes(fields: &[(&str, String)]) -> Vec<u8> {
693 let mut body = String::new();
694 for (name, value) in fields {
695 body.push_str("--");
696 body.push_str(MULTIPART_BOUNDARY);
697 body.push_str("\r\nContent-Disposition: form-data; name=\"");
698 body.push_str(name);
699 body.push_str("\"\r\n\r\n");
700 body.push_str(value);
701 body.push_str("\r\n");
702 }
703 body.push_str("--");
704 body.push_str(MULTIPART_BOUNDARY);
705 body.push_str("--\r\n");
706 body.into_bytes()
707}
708
709#[derive(Clone, Debug)]
714pub struct LighterHttpClient {
715 pub(crate) inner: Arc<LighterRawHttpClient>,
716 market_registry: Arc<MarketRegistry>,
717 clock: &'static AtomicTime,
718}
719
720impl Default for LighterHttpClient {
721 fn default() -> Self {
722 Self::new(LighterEnvironment::Mainnet, None, 60, None)
723 .expect("failed to create default Lighter HTTP client")
724 }
725}
726
727impl LighterHttpClient {
728 pub fn new(
734 environment: LighterEnvironment,
735 base_url: Option<String>,
736 timeout_secs: u64,
737 proxy_url: Option<String>,
738 ) -> LighterHttpResult<Self> {
739 let raw_client = LighterRawHttpClient::new(environment, base_url, timeout_secs, proxy_url)?;
740 Ok(Self::from_raw(raw_client))
741 }
742
743 #[must_use]
745 pub fn from_raw(raw_client: LighterRawHttpClient) -> Self {
746 Self::from_raw_with_registry(raw_client, Arc::new(MarketRegistry::new()))
747 }
748
749 #[must_use]
751 pub fn from_raw_with_registry(
752 raw_client: LighterRawHttpClient,
753 market_registry: Arc<MarketRegistry>,
754 ) -> Self {
755 Self {
756 inner: Arc::new(raw_client),
757 market_registry,
758 clock: get_atomic_clock_realtime(),
759 }
760 }
761
762 #[must_use]
764 pub fn base_url(&self) -> &str {
765 self.inner.base_url()
766 }
767
768 #[must_use]
770 pub fn environment(&self) -> LighterEnvironment {
771 self.inner.environment()
772 }
773
774 #[must_use]
776 pub fn market_registry(&self) -> Arc<MarketRegistry> {
777 self.market_registry.clone()
778 }
779
780 pub fn set_base_url(&mut self, base_url: &str) {
786 Arc::get_mut(&mut self.inner)
787 .expect("cannot override URL: raw client is shared")
788 .set_base_url(base_url);
789 }
790
791 pub async fn get_order_books(
797 &self,
798 query: &LighterOrderBooksQuery,
799 ) -> LighterHttpResult<LighterOrderBooks> {
800 let response = self.inner.get_order_books(query).await?;
801 register_order_books(&self.market_registry, &response.order_books);
802 Ok(response)
803 }
804
805 pub async fn get_order_book_details(
811 &self,
812 query: &LighterOrderBookDetailsQuery,
813 ) -> LighterHttpResult<LighterOrderBookDetails> {
814 let response = self.inner.get_order_book_details(query).await?;
815 parse_order_book_details_instruments_with_status(
816 &self.market_registry,
817 &response.order_book_details,
818 &response.spot_order_book_details,
819 self.generate_ts_init(),
820 )?;
821 Ok(response)
822 }
823
824 pub async fn get_order_book_orders(
830 &self,
831 query: &LighterOrderBookOrdersQuery,
832 ) -> LighterHttpResult<LighterOrderBookOrders> {
833 self.inner.get_order_book_orders(query).await
834 }
835
836 pub async fn get_recent_trades(
842 &self,
843 query: &LighterRecentTradesQuery,
844 ) -> LighterHttpResult<LighterTrades> {
845 self.inner.get_recent_trades(query).await
846 }
847
848 pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
854 self.inner.get_trades(query).await
855 }
856
857 pub async fn get_candles(
863 &self,
864 query: &LighterCandlesQuery,
865 ) -> LighterHttpResult<LighterCandles> {
866 self.inner.get_candles(query).await
867 }
868
869 pub async fn get_fundings(
875 &self,
876 query: &LighterFundingsQuery,
877 ) -> LighterHttpResult<LighterFundings> {
878 self.inner.get_fundings(query).await
879 }
880
881 pub async fn get_account_detail(
888 &self,
889 account_index: i64,
890 ) -> LighterHttpResult<LighterAccountDetail> {
891 let query = LighterAccountQuery {
892 by: LighterAccountLookup::Index,
893 value: account_index.to_string(),
894 };
895 self.inner
896 .get_account(&query)
897 .await?
898 .accounts
899 .into_iter()
900 .next()
901 .ok_or_else(|| {
902 LighterHttpError::Parse(format!("no account returned for index {account_index}"))
903 })
904 }
905
906 pub async fn get_account_active_orders(
912 &self,
913 query: &LighterAccountActiveOrdersQuery,
914 ) -> LighterHttpResult<LighterOrders> {
915 self.inner.get_account_active_orders(query).await
916 }
917
918 pub async fn get_account_inactive_orders(
924 &self,
925 query: &LighterAccountInactiveOrdersQuery,
926 ) -> LighterHttpResult<LighterOrders> {
927 self.inner.get_account_inactive_orders(query).await
928 }
929
930 pub async fn get_next_nonce(
936 &self,
937 account_index: i64,
938 api_key_index: u8,
939 ) -> LighterHttpResult<LighterNextNonce> {
940 let query = LighterNextNonceQuery {
941 account_index,
942 api_key_index,
943 };
944 self.inner.get_next_nonce(&query).await
945 }
946
947 pub async fn get_tx(&self, tx_hash: impl Into<String>) -> LighterHttpResult<LighterTx> {
953 self.inner
954 .get_tx(&LighterTxQuery {
955 by: LighterTxLookup::Hash,
956 value: tx_hash.into(),
957 })
958 .await
959 }
960
961 pub async fn get_maker_only_api_keys(
970 &self,
971 account_index: i64,
972 auth_token: impl Into<String>,
973 ) -> LighterHttpResult<LighterMakerOnlyApiKeys> {
974 let query = LighterMakerOnlyApiKeysQuery {
975 authorization: Some(auth_token.into()),
976 auth: None,
977 account_index,
978 };
979 self.inner.get_maker_only_api_keys(&query).await
980 }
981
982 pub async fn use_referral(
988 &self,
989 l1_address: &str,
990 referral_code: &str,
991 auth_token: &str,
992 ) -> LighterHttpResult<LighterResultCode> {
993 self.inner
994 .use_referral(l1_address, referral_code, auth_token)
995 .await
996 }
997
998 pub async fn send_tx(
1004 &self,
1005 request: &LighterSendTxRequest,
1006 ) -> LighterHttpResult<LighterSendTxResponse> {
1007 self.inner.send_tx(request).await
1008 }
1009
1010 pub async fn send_tx_batch(
1016 &self,
1017 request: &LighterSendTxBatchRequest,
1018 ) -> LighterHttpResult<LighterSendTxBatchResponse> {
1019 self.inner.send_tx_batch(request).await
1020 }
1021
1022 pub async fn request_recent_trades(
1029 &self,
1030 instrument: &InstrumentAny,
1031 limit: u16,
1032 ) -> LighterHttpResult<Vec<TradeTick>> {
1033 let market_id = self.market_index(instrument)?;
1034 let query = LighterRecentTradesQuery { market_id, limit };
1035 let response = self.inner.get_recent_trades(&query).await?;
1036 self.parse_trade_ticks(&response.trades, instrument)
1037 }
1038
1039 pub async fn request_trades(
1048 &self,
1049 instrument: &InstrumentAny,
1050 mut query: LighterTradesQuery,
1051 ) -> LighterHttpResult<Vec<TradeTick>> {
1052 if query.market_id.is_none() {
1053 query.market_id = Some(self.market_index(instrument)?);
1054 }
1055 let response = self.inner.get_trades(&query).await?;
1056 self.parse_trade_ticks(&response.trades, instrument)
1057 }
1058
1059 pub async fn request_bars(
1067 &self,
1068 instrument: &InstrumentAny,
1069 bar_type: BarType,
1070 start: Option<Timestamp>,
1071 end: Option<Timestamp>,
1072 limit: Option<u32>,
1073 ) -> LighterHttpResult<Vec<Bar>> {
1074 let market_id = self.market_index(instrument)?;
1075 let resolution = LighterCandleResolution::try_from(&bar_type)?;
1076 let interval_ms = resolution.interval_millis();
1077 let now = Timestamp::now();
1078
1079 if let (Some(start), Some(end)) = (start, end)
1080 && start >= end
1081 {
1082 return Err(LighterHttpError::Parse(format!(
1083 "invalid bar request range: start={start}, end={end}",
1084 )));
1085 }
1086
1087 let end = end.unwrap_or(now).min(now);
1088
1089 if let Some(start) = start
1090 && start >= end
1091 {
1092 return Ok(Vec::new());
1093 }
1094
1095 let requested_limit = limit.filter(|n| *n > 0).map(|n| n as usize);
1096 let target_limit = requested_limit.unwrap_or(DEFAULT_BARS_LIMIT);
1097 let start_was_unspecified = start.is_none();
1098 let end_ms = end.as_millisecond().max(0);
1099 let now_ms = now.as_millisecond();
1100
1101 if end_ms == 0 {
1102 return Ok(Vec::new());
1103 }
1104
1105 let start_ms = start.map_or_else(
1106 || {
1107 let lookback_bars = target_limit.saturating_add(1);
1108 let lookback_bars = i64::try_from(lookback_bars).unwrap_or(i64::MAX);
1109 let lookback_ms = interval_ms.saturating_mul(lookback_bars);
1110 end_ms.saturating_sub(lookback_ms)
1111 },
1112 |dt| dt.as_millisecond().max(0),
1113 );
1114
1115 if start_ms >= end_ms {
1116 return Ok(Vec::new());
1117 }
1118
1119 let mut bars = Vec::new();
1120 let mut cursor_ms = start_ms;
1121 let mut pages = 0_usize;
1122 let page_span_ms = interval_ms.saturating_mul(i64::from(LIGHTER_CANDLES_MAX_LIMIT));
1123
1124 while cursor_ms < end_ms && pages < MAX_BAR_REQUEST_PAGES {
1125 if !start_was_unspecified
1126 && let Some(limit) = requested_limit
1127 && bars.len() >= limit
1128 {
1129 break;
1130 }
1131
1132 let window_end_ms = cursor_ms.saturating_add(page_span_ms).min(end_ms);
1133 if window_end_ms <= cursor_ms {
1134 break;
1135 }
1136
1137 let query = LighterCandlesQuery {
1138 market_id,
1139 resolution,
1140 start_timestamp: cursor_ms,
1141 end_timestamp: window_end_ms,
1142 count_back: i64::from(LIGHTER_CANDLES_MAX_LIMIT),
1143 set_timestamp_to_end: Some(false),
1144 };
1145 let response = self.get_candles(&query).await?;
1146 let mut page = self.parse_bars(&response.candles, instrument, bar_type)?;
1147
1148 page.sort_by_key(|bar| bar.ts_event);
1149 for bar in page {
1150 let bar_start_ms = i64::try_from(bar.ts_event.as_u64() / 1_000_000)
1151 .map_err(|e| LighterHttpError::Parse(e.to_string()))?;
1152
1153 if bar_start_ms < cursor_ms
1154 || bar_start_ms >= end_ms
1155 || bar_start_ms.saturating_add(interval_ms) > now_ms
1156 {
1157 continue;
1158 }
1159
1160 if bars
1161 .last()
1162 .is_some_and(|last: &Bar| last.ts_event == bar.ts_event)
1163 {
1164 continue;
1165 }
1166 bars.push(bar);
1167
1168 if !start_was_unspecified
1169 && let Some(limit) = requested_limit
1170 && bars.len() >= limit
1171 {
1172 break;
1173 }
1174 }
1175
1176 cursor_ms = window_end_ms;
1177 pages += 1;
1178 }
1179
1180 let limit_satisfied =
1181 !start_was_unspecified && requested_limit.is_some_and(|limit| bars.len() >= limit);
1182 if pages >= MAX_BAR_REQUEST_PAGES && cursor_ms < end_ms && !limit_satisfied {
1183 return Err(LighterHttpError::HistoryIncomplete {
1184 data_type: "bar",
1185 pages,
1186 });
1187 }
1188
1189 if start_was_unspecified && bars.len() > target_limit {
1190 bars = bars.split_off(bars.len() - target_limit);
1191 }
1192
1193 Ok(bars)
1194 }
1195
1196 pub async fn request_funding_rates(
1208 &self,
1209 instrument: &InstrumentAny,
1210 start: Option<Timestamp>,
1211 end: Option<Timestamp>,
1212 limit: Option<usize>,
1213 ) -> LighterHttpResult<Vec<FundingRateUpdate>> {
1214 if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
1215 return Err(LighterHttpError::Parse(format!(
1216 "funding rates are only available for perpetual instruments: {}",
1217 instrument.id()
1218 )));
1219 }
1220
1221 let market_id = self.market_index(instrument)?;
1222 let resolution = LighterFundingResolution::OneHour;
1223 let interval_ms = resolution.interval_millis();
1224 let now = Timestamp::now();
1225
1226 if let (Some(start), Some(end)) = (start, end)
1227 && start >= end
1228 {
1229 return Err(LighterHttpError::Parse(format!(
1230 "invalid funding request range: start={start}, end={end}",
1231 )));
1232 }
1233
1234 let end = end.unwrap_or(now).min(now);
1235
1236 if let Some(start) = start
1237 && start >= end
1238 {
1239 return Ok(Vec::new());
1240 }
1241
1242 let requested_limit = limit.filter(|n| *n > 0);
1243 let target_limit = requested_limit.unwrap_or(DEFAULT_FUNDING_RATES_LIMIT);
1244 let start_was_unspecified = start.is_none();
1245 let end_ms = end.as_millisecond().max(0);
1246
1247 if end_ms == 0 {
1248 return Ok(Vec::new());
1249 }
1250
1251 let start_ms = start.map_or_else(
1252 || {
1253 let lookback_rows = target_limit.saturating_add(1);
1254 let lookback_rows = i64::try_from(lookback_rows).unwrap_or(i64::MAX);
1255 let lookback_ms = interval_ms.saturating_mul(lookback_rows);
1256 end_ms.saturating_sub(lookback_ms)
1257 },
1258 |dt| dt.as_millisecond().max(0),
1259 );
1260
1261 if start_ms >= end_ms {
1262 return Ok(Vec::new());
1263 }
1264
1265 let ts_init = self.generate_ts_init();
1266 let interval = Some(resolution.interval_minutes());
1267 let mut funding_rates = Vec::new();
1268 let mut cursor_ms = start_ms;
1269 let mut pages = 0_usize;
1270 let page_span_ms =
1273 interval_ms.saturating_mul(i64::from(LIGHTER_FUNDINGS_MAX_LIMIT.saturating_sub(1)));
1274
1275 while cursor_ms < end_ms && pages < MAX_FUNDING_REQUEST_PAGES {
1276 if !start_was_unspecified
1277 && let Some(limit) = requested_limit
1278 && funding_rates.len() >= limit
1279 {
1280 break;
1281 }
1282
1283 let window_end_ms = cursor_ms.saturating_add(page_span_ms).min(end_ms);
1284 if window_end_ms <= cursor_ms {
1285 break;
1286 }
1287
1288 let query = LighterFundingsQuery {
1289 market_id,
1290 resolution,
1291 start_timestamp: cursor_ms,
1292 end_timestamp: window_end_ms,
1293 count_back: i64::from(LIGHTER_FUNDINGS_MAX_LIMIT),
1294 };
1295 let response = self.get_fundings(&query).await?;
1296
1297 let mut page = Vec::with_capacity(response.fundings.len());
1298 for funding in &response.fundings {
1299 let update = parse_funding_rate_update(funding, instrument.id(), interval, ts_init)
1300 .map_err(LighterHttpError::from)?;
1301 let timestamp_ms = i64::try_from(update.ts_event.as_u64() / 1_000_000)
1302 .map_err(|e| LighterHttpError::Parse(e.to_string()))?;
1303
1304 if timestamp_ms < cursor_ms || timestamp_ms > end_ms {
1305 continue;
1306 }
1307 page.push(update);
1308 }
1309
1310 page.sort_by_key(|rate| rate.ts_event);
1311 for update in page {
1312 if funding_rates
1313 .last()
1314 .is_some_and(|last: &FundingRateUpdate| last.ts_event == update.ts_event)
1315 {
1316 continue;
1317 }
1318 funding_rates.push(update);
1319
1320 if !start_was_unspecified
1321 && let Some(limit) = requested_limit
1322 && funding_rates.len() >= limit
1323 {
1324 break;
1325 }
1326 }
1327
1328 cursor_ms = window_end_ms;
1329 pages += 1;
1330 }
1331
1332 let limit_satisfied = !start_was_unspecified
1333 && requested_limit.is_some_and(|limit| funding_rates.len() >= limit);
1334 if pages >= MAX_FUNDING_REQUEST_PAGES && cursor_ms < end_ms && !limit_satisfied {
1335 return Err(LighterHttpError::HistoryIncomplete {
1336 data_type: "funding rate",
1337 pages,
1338 });
1339 }
1340
1341 if start_was_unspecified && funding_rates.len() > target_limit {
1342 funding_rates = funding_rates.split_off(funding_rates.len() - target_limit);
1343 }
1344
1345 Ok(funding_rates)
1346 }
1347
1348 pub async fn request_instruments(&self) -> LighterHttpResult<Vec<InstrumentAny>> {
1356 self.request_instruments_for_query(&LighterOrderBookDetailsQuery::default())
1357 .await
1358 }
1359
1360 pub async fn request_instruments_with_status(
1366 &self,
1367 ) -> LighterHttpResult<Vec<(InstrumentAny, LighterMarketStatus)>> {
1368 self.request_instruments_with_status_for_query(&LighterOrderBookDetailsQuery::default())
1369 .await
1370 }
1371
1372 pub async fn request_instrument(
1379 &self,
1380 instrument_id: InstrumentId,
1381 ) -> LighterHttpResult<InstrumentAny> {
1382 self.request_instrument_with_status(instrument_id)
1383 .await
1384 .map(|(instrument, _)| instrument)
1385 }
1386
1387 pub async fn request_instrument_with_status(
1394 &self,
1395 instrument_id: InstrumentId,
1396 ) -> LighterHttpResult<(InstrumentAny, LighterMarketStatus)> {
1397 let query = LighterOrderBookDetailsQuery {
1398 market_id: self.market_registry.market_index(&instrument_id),
1399 filter: None,
1400 };
1401 let instruments = self
1402 .request_instruments_with_status_for_query(&query)
1403 .await?;
1404
1405 instruments
1406 .into_iter()
1407 .find(|(instrument, _)| instrument.id() == instrument_id)
1408 .ok_or_else(|| LighterHttpError::Parse(format!("instrument {instrument_id} not found")))
1409 }
1410
1411 pub async fn request_order_book_snapshot(
1418 &self,
1419 instrument: &InstrumentAny,
1420 limit: u16,
1421 ) -> LighterHttpResult<OrderBookDeltas> {
1422 let query = LighterOrderBookOrdersQuery {
1423 market_id: self.market_index(instrument)?,
1424 limit,
1425 };
1426 let snapshot = self.inner.get_order_book_orders(&query).await?;
1427 let ts_init = self.generate_ts_init();
1428
1429 parse_order_book_snapshot(
1430 &snapshot,
1431 instrument.id(),
1432 instrument.price_precision(),
1433 instrument.size_precision(),
1434 ts_init,
1435 ts_init,
1436 )
1437 .map_err(LighterHttpError::from)
1438 }
1439
1440 async fn request_instruments_for_query(
1441 &self,
1442 query: &LighterOrderBookDetailsQuery,
1443 ) -> LighterHttpResult<Vec<InstrumentAny>> {
1444 self.request_instruments_with_status_for_query(query)
1445 .await
1446 .map(|instruments| {
1447 instruments
1448 .into_iter()
1449 .map(|(instrument, _)| instrument)
1450 .collect()
1451 })
1452 }
1453
1454 async fn request_instruments_with_status_for_query(
1455 &self,
1456 query: &LighterOrderBookDetailsQuery,
1457 ) -> LighterHttpResult<Vec<(InstrumentAny, LighterMarketStatus)>> {
1458 let response = self.inner.get_order_book_details(query).await?;
1459 let ts_init = self.generate_ts_init();
1460 parse_order_book_details_instruments_with_status(
1461 &self.market_registry,
1462 &response.order_book_details,
1463 &response.spot_order_book_details,
1464 ts_init,
1465 )
1466 .map_err(LighterHttpError::from)
1467 }
1468
1469 fn parse_trade_ticks(
1470 &self,
1471 trades: &[LighterTrade],
1472 instrument: &InstrumentAny,
1473 ) -> LighterHttpResult<Vec<TradeTick>> {
1474 let ts_init = self.generate_ts_init();
1475 trades
1476 .iter()
1477 .map(|trade| parse_trade_tick(trade, instrument, ts_init).map_err(Into::into))
1478 .collect()
1479 }
1480
1481 fn parse_bars(
1482 &self,
1483 candles: &[LighterCandle],
1484 instrument: &InstrumentAny,
1485 bar_type: BarType,
1486 ) -> LighterHttpResult<Vec<Bar>> {
1487 let ts_init = self.generate_ts_init();
1488 candles
1489 .iter()
1490 .filter_map(|candle| {
1491 let has_positive_ohlc = candle.open > Decimal::ZERO
1492 && candle.high > Decimal::ZERO
1493 && candle.low > Decimal::ZERO
1494 && candle.close > Decimal::ZERO;
1495
1496 if !has_positive_ohlc {
1497 log::warn!(
1498 "Skipping Lighter candle at timestamp {} with non-positive OHLC values",
1499 candle.timestamp,
1500 );
1501 return None;
1502 }
1503
1504 Some(parse_candle_bar(candle, bar_type, instrument, ts_init).map_err(Into::into))
1505 })
1506 .collect()
1507 }
1508
1509 fn market_index(&self, instrument: &InstrumentAny) -> LighterHttpResult<i16> {
1510 self.market_registry
1511 .market_index(&instrument.id())
1512 .ok_or_else(|| {
1513 LighterHttpError::Parse(format!(
1514 "market index not registered for instrument {}",
1515 instrument.id()
1516 ))
1517 })
1518 }
1519
1520 fn generate_ts_init(&self) -> UnixNanos {
1521 self.clock.get_time_ns()
1522 }
1523}
1524
1525fn venue_error(code: i32, message: Option<&str>, default_message: &str) -> LighterHttpError {
1526 LighterHttpError::Venue {
1527 code: i64::from(code),
1528 message: message.unwrap_or(default_message).to_string(),
1529 }
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use rstest::rstest;
1535
1536 use super::*;
1537
1538 #[rstest]
1539 #[case(ENDPOINT_TRADES, "lighter:trades")]
1540 #[case(ENDPOINT_SEND_TX, "lighter:sendTx")]
1541 #[case(ENDPOINT_SEND_TX_BATCH, "lighter:sendTxBatch")]
1542 fn test_rate_limit_keys_uses_rest_bucket_and_route(
1543 #[case] endpoint: &str,
1544 #[case] route: &str,
1545 ) {
1546 assert_eq!(
1549 LighterRawHttpClient::rate_limit_keys(endpoint),
1550 vec![LIGHTER_REST_BUCKET.to_string(), route.to_string()],
1551 );
1552 }
1553}