1use std::{
25 collections::HashSet,
26 fmt::Debug,
27 sync::{
28 Arc,
29 atomic::{AtomicU64, Ordering},
30 },
31};
32
33use ahash::AHashMap;
34use alloy::signers::local::PrivateKeySigner;
35use nautilus_core::string::secret::{REDACTED, SecretString, zeroize_json_value};
36use nautilus_network::{
37 http::{
38 HttpClient, HttpClientError, HttpRedirectPolicy, HttpResponse, Method, RETRY_AFTER,
39 create_standard_nautilus_headers, parse_retry_after,
40 },
41 retry::{RetryConfig, RetryManager},
42};
43use serde::{Serialize, de::DeserializeOwned};
44use serde_json::Value;
45use ustr::Ustr;
46
47use crate::{
48 common::{
49 consts::{
50 HEADER_DERIVE_SIGNATURE, HEADER_DERIVE_TIMESTAMP, HEADER_DERIVE_WALLET,
51 HTTP_MAX_RETRIES, HTTP_RETRY_DELAY_INITIAL_MS, HTTP_RETRY_DELAY_MAX_MS, HTTP_TIMEOUT,
52 },
53 enums::DeriveInstrumentType,
54 rate_limit::{self, DeriveRateLimiter},
55 retry::{http_retry_config, should_retry_http_error},
56 },
57 http::{
58 error::{DeriveHttpError, Result},
59 models::{
60 DeriveCancelByLabelResult, DeriveEmptyResult, DeriveInstrument,
61 DeriveInstrumentsResult, DeriveOpenOrdersResult, DeriveOrder, DeriveOrderResult,
62 DeriveOrdersResult, DerivePositionsResult, DerivePublicCandle,
63 DerivePublicFundingRateHistoryResult, DerivePublicTradesResult, DeriveReplaceOutcome,
64 DeriveReplaceResult, DeriveSubaccount, DeriveTickerSnapshot, DeriveTickersResult,
65 DeriveTradesResult, JsonRpcResponse,
66 },
67 query::{
68 DeriveCancelAllParams, DeriveCancelByLabelParams, DeriveCancelParams,
69 DeriveGetOpenOrdersParams, DeriveGetOrderHistoryParams, DeriveGetOrderParams,
70 DeriveGetPositionsParams, DeriveGetSubaccountParams, DeriveGetTradeHistoryParams,
71 DeriveGetTriggerOrdersParams, DeriveOrderParams, DeriveReplaceParams, PaginationCursor,
72 },
73 },
74 signing::auth::{AuthHeaders, build_rest_auth_headers},
75};
76
77#[derive(Clone)]
82pub struct DeriveCredentials {
83 pub wallet_address: String,
85 pub signer: PrivateKeySigner,
87}
88
89impl DeriveCredentials {
90 pub fn new(wallet_address: impl Into<String>, session_key_hex: &str) -> Result<Self> {
96 let signer: PrivateKeySigner = session_key_hex
97 .parse()
98 .map_err(|e| DeriveHttpError::decode(format!("invalid session key: {e}")))?;
99 Ok(Self {
100 wallet_address: wallet_address.into(),
101 signer,
102 })
103 }
104}
105
106impl Debug for DeriveCredentials {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct(stringify!(DeriveCredentials))
109 .field("wallet_address", &self.wallet_address)
110 .field("signer", &REDACTED)
111 .finish()
112 }
113}
114
115#[derive(Debug, Clone)]
123pub struct DeriveHttpClient {
124 client: HttpClient,
125 base_url: String,
126 credentials: Option<DeriveCredentials>,
127 next_id: Arc<AtomicU64>,
128 timeout_secs: u64,
129 retry_manager: Arc<RetryManager<DeriveHttpError>>,
130 rate_limiter: Arc<DeriveRateLimiter>,
131}
132
133impl DeriveHttpClient {
134 pub fn new(
143 base_url: impl Into<String>,
144 timeout_secs: Option<u64>,
145 proxy_url: Option<String>,
146 retry_config: Option<RetryConfig>,
147 ) -> Result<Self> {
148 let timeout_secs = timeout_secs.unwrap_or_else(|| HTTP_TIMEOUT.as_secs());
149 let client = build_client(timeout_secs, proxy_url)?;
150 let base_url = trim_trailing_slash(base_url.into());
151 let rate_limiter = Arc::new(DeriveRateLimiter::shared(&base_url, None, None, None));
152
153 let retry_config = retry_config.unwrap_or_else(|| {
154 http_retry_config(
155 HTTP_MAX_RETRIES,
156 HTTP_RETRY_DELAY_INITIAL_MS,
157 HTTP_RETRY_DELAY_MAX_MS,
158 )
159 });
160
161 Ok(Self {
162 client,
163 base_url,
164 credentials: None,
165 next_id: Arc::new(AtomicU64::new(1)),
166 timeout_secs,
167 retry_manager: Arc::new(RetryManager::new(retry_config)),
168 rate_limiter,
169 })
170 }
171
172 pub fn with_credentials(
178 base_url: impl Into<String>,
179 credentials: DeriveCredentials,
180 timeout_secs: Option<u64>,
181 proxy_url: Option<String>,
182 retry_config: Option<RetryConfig>,
183 ) -> Result<Self> {
184 let mut client = Self::new(base_url, timeout_secs, proxy_url, retry_config)?;
185
186 client.rate_limiter = Arc::new(DeriveRateLimiter::shared(
187 &client.base_url,
188 Some(&credentials.wallet_address),
189 None,
190 None,
191 ));
192 client.credentials = Some(credentials);
193 Ok(client)
194 }
195
196 #[must_use]
198 pub fn base_url(&self) -> &str {
199 &self.base_url
200 }
201
202 #[must_use]
204 pub fn has_credentials(&self) -> bool {
205 self.credentials.is_some()
206 }
207
208 fn next_id(&self) -> u64 {
210 self.next_id.fetch_add(1, Ordering::Relaxed)
211 }
212
213 pub async fn send_public<P, R>(&self, method: &str, params: &P) -> Result<R>
222 where
223 P: Serialize + ?Sized,
224 R: DeserializeOwned,
225 {
226 let id = self.next_id();
227 self.dispatch(method, params, id, false, true, None).await
228 }
229
230 pub async fn send_private<P, R>(&self, method: &str, params: &P) -> Result<R>
241 where
242 P: Serialize + ?Sized,
243 R: DeserializeOwned,
244 {
245 if self.credentials.is_none() {
246 return Err(DeriveHttpError::MissingCredentials {
247 method: method.to_owned(),
248 });
249 }
250
251 let id = self.next_id();
252 self.dispatch(method, params, id, true, true, None).await
253 }
254
255 pub async fn send_private_once<P, R>(&self, method: &str, params: &P) -> Result<R>
276 where
277 P: Serialize + ?Sized,
278 R: DeserializeOwned,
279 {
280 if self.credentials.is_none() {
281 return Err(DeriveHttpError::MissingCredentials {
282 method: method.to_owned(),
283 });
284 }
285
286 let id = self.next_id();
287 self.dispatch(method, params, id, true, false, None).await
288 }
289
290 async fn send_private_write<P, R>(
293 &self,
294 method: &str,
295 params: &P,
296 instrument_name: Ustr,
297 ) -> Result<R>
298 where
299 P: Serialize + ?Sized,
300 R: DeserializeOwned,
301 {
302 if self.credentials.is_none() {
303 return Err(DeriveHttpError::MissingCredentials {
304 method: method.to_owned(),
305 });
306 }
307
308 let id = self.next_id();
309 self.dispatch(method, params, id, true, false, Some(instrument_name))
310 .await
311 }
312
313 pub async fn get_instruments(
324 &self,
325 currency: &str,
326 instrument_type: DeriveInstrumentType,
327 expired: bool,
328 ) -> Result<Vec<DeriveInstrument>> {
329 let mut instruments = Vec::new();
330 let mut names = HashSet::new();
331 let mut pages = PaginationCursor::new_public();
332
333 loop {
334 let params = serde_json::json!({
335 "currency": currency,
336 "instrument_type": instrument_type,
337 "expired": expired,
338 "page": pages.page(),
339 "page_size": 1000,
340 });
341 let result: DeriveInstrumentsResult = self
342 .send_public("public/get_all_instruments", ¶ms)
343 .await?;
344 let more = pages.advance(
345 &result.pagination,
346 result
347 .instruments
348 .iter()
349 .map(|row| row.instrument_name.as_str()),
350 )?;
351 instruments.extend(
352 result
353 .instruments
354 .into_iter()
355 .filter(|row| names.insert(row.instrument_name)),
356 );
357
358 if !more {
359 return Ok(instruments);
360 }
361 }
362 }
363
364 pub async fn get_instrument(&self, instrument_name: &str) -> Result<DeriveInstrument> {
374 let params = serde_json::json!({
375 "instrument_name": instrument_name,
376 });
377 let instrument: DeriveInstrument =
378 self.send_public("public/get_instrument", ¶ms).await?;
379
380 if instrument.instrument_name.as_str() != instrument_name {
381 return Err(DeriveHttpError::decode(format!(
382 "instrument response identity mismatch: requested {instrument_name}, received {}",
383 instrument.instrument_name,
384 )));
385 }
386
387 Ok(instrument)
388 }
389
390 pub async fn get_trade_history(
400 &self,
401 instrument_name: &str,
402 from_timestamp: Option<i64>,
403 to_timestamp: Option<i64>,
404 page: u32,
405 page_size: u32,
406 ) -> Result<DerivePublicTradesResult> {
407 let mut params = serde_json::Map::new();
408 params.insert("instrument_name".to_string(), instrument_name.into());
409 params.insert("page".to_string(), page.into());
410 params.insert("page_size".to_string(), page_size.into());
411 if let Some(from) = from_timestamp {
412 params.insert("from_timestamp".to_string(), from.into());
413 }
414
415 if let Some(to) = to_timestamp {
416 params.insert("to_timestamp".to_string(), to.into());
417 }
418
419 self.send_public("public/get_trade_history", &Value::Object(params))
420 .await
421 }
422
423 pub async fn get_funding_rate_history(
432 &self,
433 instrument_name: &str,
434 start_timestamp: Option<i64>,
435 end_timestamp: Option<i64>,
436 period: Option<u32>,
437 ) -> Result<DerivePublicFundingRateHistoryResult> {
438 let mut params = serde_json::Map::new();
439 params.insert("instrument_name".to_string(), instrument_name.into());
440 if let Some(start) = start_timestamp {
441 params.insert("start_timestamp".to_string(), start.into());
442 }
443
444 if let Some(end) = end_timestamp {
445 params.insert("end_timestamp".to_string(), end.into());
446 }
447
448 if let Some(period) = period {
449 params.insert("period".to_string(), period.into());
450 }
451
452 self.send_public("public/get_funding_rate_history", &Value::Object(params))
453 .await
454 }
455
456 pub async fn get_candles(
468 &self,
469 instrument_name: &str,
470 start_timestamp: i64,
471 end_timestamp: i64,
472 period: u32,
473 ) -> Result<Vec<DerivePublicCandle>> {
474 let params = serde_json::json!({
475 "instrument_name": instrument_name,
476 "start_timestamp": start_timestamp,
477 "end_timestamp": end_timestamp,
478 "period": period,
479 });
480 self.send_public("public/get_tradingview_chart_data", ¶ms)
481 .await
482 }
483
484 pub async fn get_tickers(
494 &self,
495 instrument_type: DeriveInstrumentType,
496 currency: Option<&str>,
497 expiry_date: Option<&str>,
498 ) -> Result<DeriveTickersResult> {
499 let mut params = serde_json::Map::new();
500 params.insert(
501 "instrument_type".to_string(),
502 serde_json::to_value(instrument_type).map_err(DeriveHttpError::from)?,
503 );
504
505 if let Some(currency) = currency {
506 params.insert("currency".to_string(), currency.into());
507 }
508
509 if let Some(expiry_date) = expiry_date {
510 params.insert("expiry_date".to_string(), expiry_date.into());
511 }
512
513 self.send_public("public/get_tickers", &Value::Object(params))
514 .await
515 }
516
517 pub async fn get_ticker(&self, instrument_name: &str) -> Result<DeriveTickerSnapshot> {
528 let request = ticker_request(instrument_name)?;
529 let result = self
530 .get_tickers(
531 request.instrument_type,
532 Some(request.currency),
533 request.expiry_date,
534 )
535 .await?;
536
537 let mut ticker = result
538 .tickers
539 .get(instrument_name)
540 .cloned()
541 .ok_or_else(|| {
542 DeriveHttpError::decode(format!(
543 "missing ticker `{instrument_name}` in public/get_tickers response"
544 ))
545 })?;
546
547 ticker.instrument_name = instrument_name.into();
548 Ok(ticker)
549 }
550
551 pub async fn submit_order(&self, params: &DeriveOrderParams) -> Result<DeriveOrder> {
560 let result: DeriveOrderResult = self
561 .send_private_write("private/order", params, params.instrument_name)
562 .await?;
563 Ok(result.order)
564 }
565
566 pub async fn cancel_order(&self, params: &DeriveCancelParams) -> Result<DeriveEmptyResult> {
573 self.send_private_write("private/cancel", params, params.instrument_name)
574 .await
575 }
576
577 pub async fn cancel_all(&self, params: &DeriveCancelAllParams) -> Result<DeriveEmptyResult> {
585 self.send_private_once("private/cancel_all", params).await
586 }
587
588 pub async fn cancel_by_label(
595 &self,
596 params: &DeriveCancelByLabelParams,
597 ) -> Result<DeriveCancelByLabelResult> {
598 self.send_private_once("private/cancel_by_label", params)
599 .await
600 }
601
602 pub async fn replace_order(
612 &self,
613 params: &DeriveReplaceParams,
614 ) -> Result<DeriveReplaceOutcome> {
615 let result: DeriveReplaceResult = self
616 .send_private_write("private/replace", params, params.order.instrument_name)
617 .await?;
618 result
619 .into_outcome(
620 ¶ms.order_id_to_cancel,
621 ¶ms.order.label,
622 params.order.envelope.subaccount_id,
623 params.order.instrument_name.as_str(),
624 )
625 .map_err(DeriveHttpError::decode)
626 }
627
628 pub async fn get_subaccount(
636 &self,
637 params: &DeriveGetSubaccountParams,
638 ) -> Result<DeriveSubaccount> {
639 let result: DeriveSubaccount = self.send_private("private/get_subaccount", params).await?;
640 validate_subaccount_id(params.subaccount_id, result.subaccount_id)?;
641 validate_order_subaccounts(params.subaccount_id, &result.open_orders)?;
642 Ok(result)
643 }
644
645 pub async fn get_open_orders(
652 &self,
653 params: &DeriveGetOpenOrdersParams,
654 ) -> Result<DeriveOpenOrdersResult> {
655 let result: DeriveOpenOrdersResult =
656 self.send_private("private/get_open_orders", params).await?;
657 validate_subaccount_id(params.subaccount_id, result.subaccount_id)?;
658 validate_order_subaccounts(params.subaccount_id, &result.orders)?;
659 Ok(result)
660 }
661
662 pub async fn get_trigger_orders(
669 &self,
670 params: &DeriveGetTriggerOrdersParams,
671 ) -> Result<DeriveOpenOrdersResult> {
672 let result: DeriveOpenOrdersResult = self
673 .send_private("private/get_trigger_orders", params)
674 .await?;
675 validate_subaccount_id(params.subaccount_id, result.subaccount_id)?;
676 validate_order_subaccounts(params.subaccount_id, &result.orders)?;
677 Ok(result)
678 }
679
680 pub async fn get_order(&self, params: &DeriveGetOrderParams) -> Result<DeriveOrder> {
687 let result: DeriveOrder = self.send_private("private/get_order", params).await?;
688 validate_subaccount_id(params.subaccount_id, result.subaccount_id)?;
689 Ok(result)
690 }
691
692 pub async fn get_order_history(
703 &self,
704 params: &DeriveGetOrderHistoryParams,
705 ) -> Result<DeriveOrdersResult> {
706 let result: DeriveOrdersResult = self
707 .send_private("private/get_order_history", params)
708 .await?;
709 validate_subaccount_id(params.subaccount_id, result.subaccount_id)?;
710 validate_order_subaccounts(params.subaccount_id, &result.orders)?;
711 Ok(result)
712 }
713
714 pub async fn get_private_trade_history(
721 &self,
722 params: &DeriveGetTradeHistoryParams,
723 ) -> Result<DeriveTradesResult> {
724 let result: DeriveTradesResult = self
725 .send_private("private/get_trade_history", params)
726 .await?;
727 validate_subaccount_id(params.subaccount_id, result.subaccount_id)?;
728 for trade in &result.trades {
729 validate_subaccount_id(params.subaccount_id, trade.subaccount_id)?;
730 }
731
732 Ok(result)
733 }
734
735 pub async fn get_positions(
742 &self,
743 params: &DeriveGetPositionsParams,
744 ) -> Result<DerivePositionsResult> {
745 let result: DerivePositionsResult =
746 self.send_private("private/get_positions", params).await?;
747 validate_subaccount_id(params.subaccount_id, result.subaccount_id)?;
748 Ok(result)
749 }
750
751 async fn dispatch<P, R>(
752 &self,
753 method: &str,
754 params: &P,
755 id: u64,
756 authenticate: bool,
757 retry: bool,
758 instrument_name: Option<Ustr>,
759 ) -> Result<R>
760 where
761 P: Serialize + ?Sized,
762 R: DeserializeOwned,
763 {
764 let url = format!("{}/{}", self.base_url, method.trim_start_matches('/'));
765 let mut body_value = serde_json::to_value(params).map_err(DeriveHttpError::from)?;
766 let body = serde_json::to_string(&body_value).map_err(DeriveHttpError::from);
767
768 let rate_class = rate_limit::rate_class_for_method(method);
769
770 let instrument_name = instrument_name.or_else(|| {
771 body_value
772 .get("instrument_name")
773 .and_then(Value::as_str)
774 .map(Ustr::from)
775 });
776
777 zeroize_json_value(&mut body_value);
778 let body = SecretString::from(body?);
779
780 let attempt = || async {
786 if authenticate {
787 self.rate_limiter
788 .await_class_ready(rate_class, instrument_name.as_ref())
789 .await;
790 } else {
791 self.rate_limiter.await_public_ready().await;
792 }
793
794 let mut headers: AHashMap<String, String> = AHashMap::with_capacity(4);
795 headers.insert("Content-Type".to_string(), "application/json".to_string());
796
797 if authenticate {
798 let auth = self.build_auth_headers(method)?;
799 headers.insert(HEADER_DERIVE_WALLET.to_string(), auth.wallet);
800 headers.insert(HEADER_DERIVE_TIMESTAMP.to_string(), auth.timestamp);
801 headers.insert(
802 HEADER_DERIVE_SIGNATURE.to_string(),
803 auth.signature.into_inner(),
804 );
805 }
806
807 let response = self
808 .client
809 .request_with_secret_body(
810 Method::POST,
811 url.clone(),
812 None,
813 Some(headers.into_iter().collect()),
814 body.clone(),
815 Some(self.timeout_secs),
816 None,
817 )
818 .await
819 .map_err(DeriveHttpError::from)?;
820
821 decode_envelope(method, id, response)
822 };
823
824 if retry {
825 self.retry_manager
826 .invocation(
827 method,
828 attempt,
829 should_retry_http_error,
830 DeriveHttpError::Retry,
831 )
832 .retry_delay(&DeriveHttpError::retry_after)
833 .execute()
834 .await
835 } else {
836 attempt().await
837 }
838 }
839
840 fn build_auth_headers(&self, method: &str) -> Result<AuthHeaders> {
841 let credentials =
842 self.credentials
843 .as_ref()
844 .ok_or_else(|| DeriveHttpError::MissingCredentials {
845 method: method.to_owned(),
846 })?;
847
848 let auth = build_rest_auth_headers(&credentials.wallet_address, &credentials.signer)?;
849 Ok(auth)
850 }
851}
852
853#[derive(Debug, Clone, Copy)]
854struct TickerRequest<'a> {
855 instrument_type: DeriveInstrumentType,
856 currency: &'a str,
857 expiry_date: Option<&'a str>,
858}
859
860fn ticker_request(instrument_name: &str) -> Result<TickerRequest<'_>> {
861 let Some((currency, suffix)) = instrument_name.split_once('-') else {
862 return Err(DeriveHttpError::decode(format!(
863 "invalid Derive instrument name `{instrument_name}`"
864 )));
865 };
866
867 if suffix == "PERP" {
868 return Ok(TickerRequest {
869 instrument_type: DeriveInstrumentType::Perp,
870 currency,
871 expiry_date: None,
872 });
873 }
874
875 let mut parts = suffix.split('-');
876
877 let Some(expiry_date) = parts.next() else {
878 return Ok(TickerRequest {
879 instrument_type: DeriveInstrumentType::Erc20,
880 currency,
881 expiry_date: None,
882 });
883 };
884
885 let has_option_tail = parts.clone().count() == 2;
886 if expiry_date.len() == 8 && expiry_date.chars().all(|c| c.is_ascii_digit()) && has_option_tail
887 {
888 return Ok(TickerRequest {
889 instrument_type: DeriveInstrumentType::Option,
890 currency,
891 expiry_date: Some(expiry_date),
892 });
893 }
894
895 Ok(TickerRequest {
896 instrument_type: DeriveInstrumentType::Erc20,
897 currency,
898 expiry_date: None,
899 })
900}
901
902fn build_client(
903 timeout_secs: u64,
904 proxy_url: Option<String>,
905) -> std::result::Result<HttpClient, HttpClientError> {
906 HttpClient::builder()
907 .redirect_policy(HttpRedirectPolicy::Reject)
908 .headers(create_standard_nautilus_headers().into_iter().collect())
909 .header_keys(vec![RETRY_AFTER.as_str().to_string()])
910 .timeout_secs(timeout_secs)
911 .maybe_proxy_url(proxy_url)
912 .rate_limiters(Vec::new())
913 .build()
914}
915
916fn trim_trailing_slash(url: String) -> String {
917 if url.ends_with('/') {
918 url.trim_end_matches('/').to_string()
919 } else {
920 url
921 }
922}
923
924fn decode_envelope<R: DeserializeOwned>(
925 method: &str,
926 request_id: u64,
927 response: HttpResponse,
928) -> Result<R> {
929 let status = response.status.as_u16();
930
931 let retry_after = response
932 .headers
933 .get(RETRY_AFTER.as_str())
934 .and_then(|value| {
935 parse_retry_after(
936 value,
937 nautilus_core::UnixNanos::from(nautilus_core::time::nanos_since_unix_epoch())
938 .to_datetime_utc(),
939 )
940 });
941
942 let is_success_status = (200..300).contains(&status);
943 let body = response.body;
944
945 let envelope: JsonRpcResponse<R> = match serde_json::from_slice(&body) {
946 Ok(env) => env,
947 Err(e) => {
948 if !is_success_status {
949 let text = String::from_utf8_lossy(&body).into_owned();
950 return Err(DeriveHttpError::Http {
951 status,
952 message: truncate(text, 512),
953 retry_after,
954 });
955 }
956
957 return Err(DeriveHttpError::decode(format!(
958 "failed to decode `{method}` response: {e}",
959 )));
960 }
961 };
962
963 if let Some(err) = envelope.error {
964 return Err(DeriveHttpError::JsonRpc {
965 code: err.code,
966 message: err.message,
967 data: err.data,
968 retry_after,
969 });
970 }
971
972 if !is_success_status {
977 let text = String::from_utf8_lossy(&body).into_owned();
978 return Err(DeriveHttpError::Http {
979 status,
980 message: truncate(text, 512),
981 retry_after,
982 });
983 }
984
985 if let Some(echoed) = envelope.id
986 && echoed != request_id
987 {
988 log::debug!(
989 "derive: id mismatch for `{method}` (sent={request_id}, recv={echoed}); accepting result",
990 );
991 }
992
993 envelope
994 .result
995 .ok_or_else(|| DeriveHttpError::MissingResult {
996 method: method.to_owned(),
997 })
998}
999
1000fn truncate(s: String, max: usize) -> String {
1001 if s.len() <= max {
1002 return s;
1003 }
1004
1005 let mut cutoff = max;
1006 while cutoff > 0 && !s.is_char_boundary(cutoff) {
1007 cutoff -= 1;
1008 }
1009
1010 let mut out = String::with_capacity(cutoff + 3);
1011 out.push_str(&s[..cutoff]);
1012 out.push_str("...");
1013 out
1014}
1015
1016fn validate_order_subaccounts(expected: u64, orders: &[DeriveOrder]) -> Result<()> {
1017 for order in orders {
1018 validate_subaccount_id(expected, order.subaccount_id)?;
1019 }
1020
1021 Ok(())
1022}
1023
1024fn validate_subaccount_id(expected: u64, received: i64) -> Result<()> {
1025 if u64::try_from(received).ok() != Some(expected) {
1026 return Err(DeriveHttpError::decode(format!(
1027 "subaccount response identity mismatch: requested {expected}, received {received}",
1028 )));
1029 }
1030
1031 Ok(())
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use std::collections::HashMap;
1037
1038 use nautilus_network::http::{HttpStatus, StatusCode};
1039 use nautilus_testkit::http::assert_http_redirect_rejected;
1040 use rstest::rstest;
1041
1042 use super::*;
1043
1044 const SESSION_KEY_HEX: &str =
1045 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
1046 const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
1047
1048 fn test_client() -> DeriveHttpClient {
1049 DeriveHttpClient::new("https://api.example/", None, None, None).expect("client builds")
1050 }
1051
1052 fn test_response(status: u16, body: &serde_json::Value) -> HttpResponse {
1053 let status_code = StatusCode::from_u16(status).unwrap();
1054
1055 HttpResponse {
1056 status: HttpStatus::new(status_code),
1057 headers: HashMap::new(),
1058 body: serde_json::to_vec(body).unwrap().into(),
1059 }
1060 }
1061
1062 #[tokio::test]
1063 async fn test_authenticated_client_rejects_redirects() {
1064 let client = build_client(3, None).unwrap();
1065 assert_http_redirect_rejected(|url| async move {
1066 client
1067 .get(url, None, None, Some(3), None)
1068 .await
1069 .unwrap()
1070 .status
1071 .as_u16()
1072 })
1073 .await;
1074 }
1075
1076 #[rstest]
1077 fn test_credentials_debug_redacts_signer() {
1078 let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
1079 let dbg = format!("{creds:?}");
1080 assert!(dbg.contains(REDACTED));
1081 assert!(dbg.contains(TEST_WALLET));
1082 assert!(!dbg.contains(SESSION_KEY_HEX));
1083 }
1084
1085 #[rstest]
1086 fn test_credentials_rejects_invalid_session_key() {
1087 let err = DeriveCredentials::new(TEST_WALLET, "not-hex").expect_err("must reject");
1088 match err {
1089 DeriveHttpError::Decode(msg) => assert!(msg.contains("invalid session key")),
1090 other => panic!("expected Decode, was {other:?}"),
1091 }
1092 }
1093
1094 #[rstest]
1095 fn test_base_url_trims_trailing_slash() {
1096 let client = test_client();
1097 assert_eq!(client.base_url(), "https://api.example");
1098 }
1099
1100 #[rstest]
1101 fn test_new_has_no_credentials() {
1102 assert!(!test_client().has_credentials());
1103 }
1104
1105 #[rstest]
1106 fn test_with_credentials_sets_creds() {
1107 let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
1108 let client =
1109 DeriveHttpClient::with_credentials("https://api.example", creds, None, None, None)
1110 .unwrap();
1111 assert!(client.has_credentials());
1112 }
1113
1114 #[rstest]
1115 fn test_next_id_increments_monotonically() {
1116 let client = test_client();
1117 let a = client.next_id();
1118 let b = client.next_id();
1119 let c = client.next_id();
1120 assert_eq!(b, a + 1);
1121 assert_eq!(c, b + 1);
1122 }
1123
1124 #[rstest]
1125 fn test_decode_envelope_returns_result() {
1126 let resp = test_response(200, &serde_json::json!({"id": 1, "result": {"ok": true}}));
1127 let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
1128 assert_eq!(value["ok"], true);
1129 }
1130
1131 #[rstest]
1132 fn test_decode_envelope_accepts_null_empty_result() {
1133 let resp = test_response(200, &serde_json::json!({"id": 1, "result": null}));
1134 let result: DeriveEmptyResult = decode_envelope("private/cancel", 1, resp).unwrap();
1135 assert_eq!(result, DeriveEmptyResult {});
1136 }
1137
1138 #[rstest]
1139 fn test_decode_envelope_propagates_jsonrpc_error() {
1140 let resp = test_response(
1141 200,
1142 &serde_json::json!({
1143 "id": 1,
1144 "error": {"code": -32601, "message": "Method not found"}
1145 }),
1146 );
1147 let err: DeriveHttpError = decode_envelope::<Value>("public/missing", 1, resp).unwrap_err();
1148 match err {
1149 DeriveHttpError::JsonRpc { code, message, .. } => {
1150 assert_eq!(code, -32601);
1151 assert_eq!(message, "Method not found");
1152 }
1153 other => panic!("expected JsonRpc, was {other:?}"),
1154 }
1155 }
1156
1157 #[rstest]
1158 fn test_decode_envelope_flags_missing_result() {
1159 let resp = test_response(200, &serde_json::json!({"id": 1}));
1160 let err = decode_envelope::<Value>("public/get_instruments", 1, resp).unwrap_err();
1161 assert!(matches!(err, DeriveHttpError::MissingResult { .. }));
1162 }
1163
1164 #[rstest]
1165 fn test_decode_envelope_flags_non_2xx_with_unparsable_body() {
1166 let status_code = StatusCode::from_u16(503).unwrap();
1167
1168 let response = HttpResponse {
1169 status: HttpStatus::new(status_code),
1170 headers: HashMap::new(),
1171 body: bytes::Bytes::from_static(b"<html>upstream down</html>"),
1172 };
1173
1174 let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1175 match err {
1176 DeriveHttpError::Http {
1177 status, message, ..
1178 } => {
1179 assert_eq!(status, 503);
1180 assert!(message.contains("upstream down"));
1181 }
1182 other => panic!("expected Http, was {other:?}"),
1183 }
1184 }
1185
1186 #[rstest]
1187 fn test_decode_envelope_flags_non_2xx_with_non_envelope_json() {
1188 let resp = test_response(401, &serde_json::json!({"message": "Unauthorized"}));
1191 let err = decode_envelope::<Value>("private/order", 1, resp).unwrap_err();
1192 match err {
1193 DeriveHttpError::Http {
1194 status, message, ..
1195 } => {
1196 assert_eq!(status, 401);
1197 assert!(message.contains("Unauthorized"));
1198 }
1199 other => panic!("expected Http, was {other:?}"),
1200 }
1201 }
1202
1203 #[rstest]
1204 fn test_decode_envelope_prefers_jsonrpc_error_over_http_status() {
1205 let status_code = StatusCode::from_u16(400).unwrap();
1208 let body = serde_json::json!({
1209 "id": 1,
1210 "error": {"code": -32602, "message": "Invalid params"},
1211 });
1212
1213 let response = HttpResponse {
1214 status: HttpStatus::new(status_code),
1215 headers: HashMap::new(),
1216 body: serde_json::to_vec(&body).unwrap().into(),
1217 };
1218
1219 let err = decode_envelope::<Value>("private/order", 1, response).unwrap_err();
1220 assert!(matches!(err, DeriveHttpError::JsonRpc { code: -32602, .. }));
1221 }
1222
1223 #[rstest]
1224 fn test_truncate_handles_multi_byte_char_at_boundary() {
1225 let s = "ΩΩΩΩΩΩΩΩΩΩ".to_string();
1228 assert_eq!(s.len(), 20);
1229 let out = truncate(s, 5);
1230 assert!(out.ends_with("..."));
1231 let prefix = out.trim_end_matches("...");
1232 assert!(prefix.is_char_boundary(prefix.len()));
1233 assert!(prefix.chars().all(|c| c == 'Ω'));
1234 }
1235
1236 #[rstest]
1237 fn test_truncate_returns_input_when_under_limit() {
1238 let s = "short".to_string();
1239 assert_eq!(truncate(s, 16), "short");
1240 }
1241
1242 #[rstest]
1243 fn test_decode_envelope_non_2xx_body_with_non_ascii_does_not_panic() {
1244 let glyph = "Ω";
1247 let body = glyph.repeat(600);
1248 let status_code = StatusCode::from_u16(503).unwrap();
1249
1250 let response = HttpResponse {
1251 status: HttpStatus::new(status_code),
1252 headers: HashMap::new(),
1253 body: body.into_bytes().into(),
1254 };
1255
1256 let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1257 assert!(matches!(err, DeriveHttpError::Http { status: 503, .. }));
1258 }
1259
1260 #[rstest]
1261 fn test_decode_envelope_accepts_id_mismatch() {
1262 let resp = test_response(200, &serde_json::json!({"id": 99, "result": "ok"}));
1263 let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
1264 assert_eq!(value, serde_json::json!("ok"));
1265 }
1266
1267 #[tokio::test]
1268 async fn test_send_private_without_credentials_errors() {
1269 let client = test_client();
1270 let err = client
1271 .send_private::<_, Value>("private/order", &serde_json::json!({}))
1272 .await
1273 .expect_err("must require credentials");
1274
1275 match err {
1276 DeriveHttpError::MissingCredentials { method } => {
1277 assert_eq!(method, "private/order");
1278 }
1279 other => panic!("expected MissingCredentials, was {other:?}"),
1280 }
1281 }
1282}