1use std::{
25 fmt::Debug,
26 sync::{
27 Arc,
28 atomic::{AtomicU64, Ordering},
29 },
30};
31
32use ahash::AHashMap;
33use alloy::signers::local::PrivateKeySigner;
34use nautilus_core::string::secret::REDACTED;
35use nautilus_network::{
36 http::{
37 HttpClient, HttpClientError, HttpRedirectPolicy, HttpResponse,
38 create_standard_nautilus_headers,
39 },
40 ratelimiter::clock::MonotonicClock,
41 retry::{RetryConfig, RetryManager},
42};
43use serde::{Serialize, de::DeserializeOwned};
44use serde_json::Value;
45use ustr::Ustr;
46
47use crate::{
48 common::{
49 consts::{HEADER_LYRA_SIGNATURE, HEADER_LYRA_TIMESTAMP, HEADER_LYRA_WALLET, HTTP_TIMEOUT},
50 enums::DeriveInstrumentType,
51 rate_limit::{self, DeriveRateLimiter, FixedWindowLimiter},
52 retry::{http_retry_config, should_retry_http_error},
53 },
54 http::{
55 error::{DeriveHttpError, Result},
56 models::{
57 DeriveCancelByLabelResult, DeriveEmptyResult, DeriveInstrument, DeriveOpenOrdersResult,
58 DeriveOrder, DeriveOrderResult, DeriveOrdersResult, DerivePositionsResult,
59 DerivePublicCandle, DerivePublicFundingRateHistoryResult, DerivePublicTradesResult,
60 DeriveReplaceOutcome, DeriveReplaceResult, DeriveSubaccount, DeriveTickerSnapshot,
61 DeriveTickersResult, DeriveTradesResult, JsonRpcResponse,
62 },
63 query::{
64 DeriveCancelAllParams, DeriveCancelByLabelParams, DeriveCancelParams,
65 DeriveGetOpenOrdersParams, DeriveGetOrderHistoryParams, DeriveGetOrderParams,
66 DeriveGetPositionsParams, DeriveGetSubaccountParams, DeriveGetTradeHistoryParams,
67 DeriveGetTriggerOrdersParams, DeriveOrderParams, DeriveReplaceParams,
68 },
69 },
70 signing::auth::{AuthHeaders, build_rest_auth_headers},
71};
72
73#[derive(Clone)]
78pub struct DeriveCredentials {
79 pub wallet_address: String,
81 pub signer: PrivateKeySigner,
83}
84
85impl DeriveCredentials {
86 pub fn new(wallet_address: impl Into<String>, session_key_hex: &str) -> Result<Self> {
93 let signer: PrivateKeySigner = session_key_hex
94 .parse()
95 .map_err(|e| DeriveHttpError::decode(format!("invalid session key: {e}")))?;
96 Ok(Self {
97 wallet_address: wallet_address.into(),
98 signer,
99 })
100 }
101}
102
103impl Debug for DeriveCredentials {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct(stringify!(DeriveCredentials))
106 .field("wallet_address", &self.wallet_address)
107 .field("signer", &REDACTED)
108 .finish()
109 }
110}
111
112#[derive(Debug, Clone)]
120pub struct DeriveHttpClient {
121 client: HttpClient,
122 base_url: String,
123 credentials: Option<DeriveCredentials>,
124 next_id: Arc<AtomicU64>,
125 timeout_secs: u64,
126 retry_manager: Arc<RetryManager<DeriveHttpError>>,
127 rate_limiter: Arc<DeriveRateLimiter>,
128}
129
130impl DeriveHttpClient {
131 pub fn new(
140 base_url: impl Into<String>,
141 timeout_secs: Option<u64>,
142 proxy_url: Option<String>,
143 retry_config: Option<RetryConfig>,
144 ) -> Result<Self> {
145 let timeout_secs = timeout_secs.unwrap_or_else(|| HTTP_TIMEOUT.as_secs());
146 let (client, rate_limiter) = build_client(timeout_secs, proxy_url)?;
147 let retry_config = retry_config.unwrap_or_else(|| http_retry_config(3, 100, 5_000));
148 Ok(Self {
149 client,
150 base_url: trim_trailing_slash(base_url.into()),
151 credentials: None,
152 next_id: Arc::new(AtomicU64::new(1)),
153 timeout_secs,
154 retry_manager: Arc::new(RetryManager::new(retry_config)),
155 rate_limiter,
156 })
157 }
158
159 pub fn with_credentials(
166 base_url: impl Into<String>,
167 credentials: DeriveCredentials,
168 timeout_secs: Option<u64>,
169 proxy_url: Option<String>,
170 retry_config: Option<RetryConfig>,
171 ) -> Result<Self> {
172 let mut client = Self::new(base_url, timeout_secs, proxy_url, retry_config)?;
173 client.credentials = Some(credentials);
174 Ok(client)
175 }
176
177 #[must_use]
179 pub fn base_url(&self) -> &str {
180 &self.base_url
181 }
182
183 #[must_use]
185 pub fn has_credentials(&self) -> bool {
186 self.credentials.is_some()
187 }
188
189 fn next_id(&self) -> u64 {
191 self.next_id.fetch_add(1, Ordering::Relaxed)
192 }
193
194 pub async fn send_public<P, R>(&self, method: &str, params: &P) -> Result<R>
203 where
204 P: Serialize + ?Sized,
205 R: DeserializeOwned,
206 {
207 let id = self.next_id();
208 self.dispatch(method, params, id, false, true, None).await
209 }
210
211 pub async fn send_private<P, R>(&self, method: &str, params: &P) -> Result<R>
223 where
224 P: Serialize + ?Sized,
225 R: DeserializeOwned,
226 {
227 if self.credentials.is_none() {
228 return Err(DeriveHttpError::MissingCredentials {
229 method: method.to_owned(),
230 });
231 }
232 let id = self.next_id();
233 self.dispatch(method, params, id, true, true, None).await
234 }
235
236 pub async fn send_private_once<P, R>(&self, method: &str, params: &P) -> Result<R>
260 where
261 P: Serialize + ?Sized,
262 R: DeserializeOwned,
263 {
264 if self.credentials.is_none() {
265 return Err(DeriveHttpError::MissingCredentials {
266 method: method.to_owned(),
267 });
268 }
269 let id = self.next_id();
270 self.dispatch(method, params, id, true, false, None).await
271 }
272
273 async fn send_private_write<P, R>(
276 &self,
277 method: &str,
278 params: &P,
279 instrument_name: Ustr,
280 ) -> Result<R>
281 where
282 P: Serialize + ?Sized,
283 R: DeserializeOwned,
284 {
285 if self.credentials.is_none() {
286 return Err(DeriveHttpError::MissingCredentials {
287 method: method.to_owned(),
288 });
289 }
290 let id = self.next_id();
291 self.dispatch(method, params, id, true, false, Some(instrument_name))
292 .await
293 }
294
295 pub async fn get_instruments(
304 &self,
305 currency: &str,
306 instrument_type: DeriveInstrumentType,
307 expired: bool,
308 ) -> Result<Vec<DeriveInstrument>> {
309 let params = serde_json::json!({
310 "currency": currency,
311 "instrument_type": instrument_type,
312 "expired": expired,
313 });
314 self.send_public("public/get_instruments", ¶ms).await
315 }
316
317 pub async fn get_instrument(&self, instrument_name: &str) -> Result<DeriveInstrument> {
327 let params = serde_json::json!({
328 "instrument_name": instrument_name,
329 });
330 self.send_public("public/get_instrument", ¶ms).await
331 }
332
333 pub async fn get_trade_history(
343 &self,
344 instrument_name: &str,
345 from_timestamp: Option<i64>,
346 to_timestamp: Option<i64>,
347 page: u32,
348 page_size: u32,
349 ) -> Result<DerivePublicTradesResult> {
350 let mut params = serde_json::Map::new();
351 params.insert("instrument_name".to_string(), instrument_name.into());
352 params.insert("page".to_string(), page.into());
353 params.insert("page_size".to_string(), page_size.into());
354 if let Some(from) = from_timestamp {
355 params.insert("from_timestamp".to_string(), from.into());
356 }
357
358 if let Some(to) = to_timestamp {
359 params.insert("to_timestamp".to_string(), to.into());
360 }
361
362 self.send_public("public/get_trade_history", &Value::Object(params))
363 .await
364 }
365
366 pub async fn get_funding_rate_history(
375 &self,
376 instrument_name: &str,
377 start_timestamp: Option<i64>,
378 end_timestamp: Option<i64>,
379 period: Option<u32>,
380 ) -> Result<DerivePublicFundingRateHistoryResult> {
381 let mut params = serde_json::Map::new();
382 params.insert("instrument_name".to_string(), instrument_name.into());
383 if let Some(start) = start_timestamp {
384 params.insert("start_timestamp".to_string(), start.into());
385 }
386
387 if let Some(end) = end_timestamp {
388 params.insert("end_timestamp".to_string(), end.into());
389 }
390
391 if let Some(period) = period {
392 params.insert("period".to_string(), period.into());
393 }
394
395 self.send_public("public/get_funding_rate_history", &Value::Object(params))
396 .await
397 }
398
399 pub async fn get_candles(
411 &self,
412 instrument_name: &str,
413 start_timestamp: i64,
414 end_timestamp: i64,
415 period: u32,
416 ) -> Result<Vec<DerivePublicCandle>> {
417 let params = serde_json::json!({
418 "instrument_name": instrument_name,
419 "start_timestamp": start_timestamp,
420 "end_timestamp": end_timestamp,
421 "period": period,
422 });
423 self.send_public("public/get_tradingview_chart_data", ¶ms)
424 .await
425 }
426
427 pub async fn get_tickers(
437 &self,
438 instrument_type: DeriveInstrumentType,
439 currency: Option<&str>,
440 expiry_date: Option<&str>,
441 ) -> Result<DeriveTickersResult> {
442 let mut params = serde_json::Map::new();
443 params.insert(
444 "instrument_type".to_string(),
445 serde_json::to_value(instrument_type).map_err(DeriveHttpError::from)?,
446 );
447
448 if let Some(currency) = currency {
449 params.insert("currency".to_string(), currency.into());
450 }
451
452 if let Some(expiry_date) = expiry_date {
453 params.insert("expiry_date".to_string(), expiry_date.into());
454 }
455
456 self.send_public("public/get_tickers", &Value::Object(params))
457 .await
458 }
459
460 pub async fn get_ticker(&self, instrument_name: &str) -> Result<DeriveTickerSnapshot> {
471 let request = ticker_request(instrument_name)?;
472 let result = self
473 .get_tickers(
474 request.instrument_type,
475 Some(request.currency),
476 request.expiry_date,
477 )
478 .await?;
479 let mut ticker = result
480 .tickers
481 .get(instrument_name)
482 .cloned()
483 .ok_or_else(|| {
484 DeriveHttpError::decode(format!(
485 "missing ticker `{instrument_name}` in public/get_tickers response"
486 ))
487 })?;
488 ticker.instrument_name = instrument_name.into();
489 Ok(ticker)
490 }
491
492 pub async fn submit_order(&self, params: &DeriveOrderParams) -> Result<DeriveOrder> {
501 let result: DeriveOrderResult = self
502 .send_private_write("private/order", params, params.instrument_name)
503 .await?;
504 Ok(result.order)
505 }
506
507 pub async fn cancel_order(&self, params: &DeriveCancelParams) -> Result<DeriveEmptyResult> {
514 self.send_private_write("private/cancel", params, params.instrument_name)
515 .await
516 }
517
518 pub async fn cancel_all(&self, params: &DeriveCancelAllParams) -> Result<DeriveEmptyResult> {
526 self.send_private_once("private/cancel_all", params).await
527 }
528
529 pub async fn cancel_by_label(
536 &self,
537 params: &DeriveCancelByLabelParams,
538 ) -> Result<DeriveCancelByLabelResult> {
539 self.send_private_once("private/cancel_by_label", params)
540 .await
541 }
542
543 pub async fn replace_order(
553 &self,
554 params: &DeriveReplaceParams,
555 ) -> Result<DeriveReplaceOutcome> {
556 let result: DeriveReplaceResult = self
557 .send_private_write("private/replace", params, params.order.instrument_name)
558 .await?;
559 result
560 .into_outcome(¶ms.order_id_to_cancel, ¶ms.order.label)
561 .map_err(DeriveHttpError::decode)
562 }
563
564 pub async fn get_subaccount(
572 &self,
573 params: &DeriveGetSubaccountParams,
574 ) -> Result<DeriveSubaccount> {
575 self.send_private("private/get_subaccount", params).await
576 }
577
578 pub async fn get_open_orders(
585 &self,
586 params: &DeriveGetOpenOrdersParams,
587 ) -> Result<DeriveOpenOrdersResult> {
588 self.send_private("private/get_open_orders", params).await
589 }
590
591 pub async fn get_trigger_orders(
598 &self,
599 params: &DeriveGetTriggerOrdersParams,
600 ) -> Result<DeriveOpenOrdersResult> {
601 self.send_private("private/get_trigger_orders", params)
602 .await
603 }
604
605 pub async fn get_order(&self, params: &DeriveGetOrderParams) -> Result<DeriveOrder> {
612 self.send_private("private/get_order", params).await
613 }
614
615 pub async fn get_order_history(
626 &self,
627 params: &DeriveGetOrderHistoryParams,
628 ) -> Result<DeriveOrdersResult> {
629 self.send_private("private/get_order_history", params).await
630 }
631
632 pub async fn get_private_trade_history(
639 &self,
640 params: &DeriveGetTradeHistoryParams,
641 ) -> Result<DeriveTradesResult> {
642 self.send_private("private/get_trade_history", params).await
643 }
644
645 pub async fn get_positions(
652 &self,
653 params: &DeriveGetPositionsParams,
654 ) -> Result<DerivePositionsResult> {
655 self.send_private("private/get_positions", params).await
656 }
657
658 async fn dispatch<P, R>(
659 &self,
660 method: &str,
661 params: &P,
662 id: u64,
663 authenticate: bool,
664 retry: bool,
665 instrument_name: Option<Ustr>,
666 ) -> Result<R>
667 where
668 P: Serialize + ?Sized,
669 R: DeserializeOwned,
670 {
671 let url = format!("{}/{}", self.base_url, method.trim_start_matches('/'));
672 let body_value = serde_json::to_value(params).map_err(DeriveHttpError::from)?;
673 let body = serde_json::to_vec(&body_value).map_err(DeriveHttpError::from)?;
674
675 let rate_class = rate_limit::rate_class_for_method(method);
676
677 let attempt = || async {
683 self.rate_limiter
684 .await_class_ready(rate_class, instrument_name.as_ref())
685 .await;
686
687 let mut headers: AHashMap<String, String> = AHashMap::with_capacity(4);
688 headers.insert("Content-Type".to_string(), "application/json".to_string());
689
690 if authenticate {
691 let auth = self.build_auth_headers(method)?;
692 headers.insert(HEADER_LYRA_WALLET.to_string(), auth.wallet);
693 headers.insert(HEADER_LYRA_TIMESTAMP.to_string(), auth.timestamp);
694 headers.insert(
695 HEADER_LYRA_SIGNATURE.to_string(),
696 auth.signature.into_inner(),
697 );
698 }
699
700 let response = self
701 .client
702 .post(
703 url.clone(),
704 None,
705 Some(headers.into_iter().collect()),
706 Some(body.clone()),
707 Some(self.timeout_secs),
708 None,
709 )
710 .await
711 .map_err(DeriveHttpError::from)?;
712
713 decode_envelope(method, id, response)
714 };
715
716 if retry {
717 self.retry_manager
718 .invocation(method, attempt, should_retry_http_error, |e| {
719 DeriveHttpError::transport(e.to_string())
720 })
721 .execute()
722 .await
723 } else {
724 attempt().await
725 }
726 }
727
728 fn build_auth_headers(&self, method: &str) -> Result<AuthHeaders> {
729 let credentials =
730 self.credentials
731 .as_ref()
732 .ok_or_else(|| DeriveHttpError::MissingCredentials {
733 method: method.to_owned(),
734 })?;
735 let auth = build_rest_auth_headers(&credentials.wallet_address, &credentials.signer)?;
736 Ok(auth)
737 }
738}
739
740#[derive(Debug, Clone, Copy)]
741struct TickerRequest<'a> {
742 instrument_type: DeriveInstrumentType,
743 currency: &'a str,
744 expiry_date: Option<&'a str>,
745}
746
747fn ticker_request(instrument_name: &str) -> Result<TickerRequest<'_>> {
748 let Some((currency, suffix)) = instrument_name.split_once('-') else {
749 return Err(DeriveHttpError::decode(format!(
750 "invalid Derive instrument name `{instrument_name}`"
751 )));
752 };
753
754 if suffix == "PERP" {
755 return Ok(TickerRequest {
756 instrument_type: DeriveInstrumentType::Perp,
757 currency,
758 expiry_date: None,
759 });
760 }
761
762 let mut parts = suffix.split('-');
763 let Some(expiry_date) = parts.next() else {
764 return Ok(TickerRequest {
765 instrument_type: DeriveInstrumentType::Erc20,
766 currency,
767 expiry_date: None,
768 });
769 };
770 let has_option_tail = parts.clone().count() == 2;
771 if expiry_date.len() == 8 && expiry_date.chars().all(|c| c.is_ascii_digit()) && has_option_tail
772 {
773 return Ok(TickerRequest {
774 instrument_type: DeriveInstrumentType::Option,
775 currency,
776 expiry_date: Some(expiry_date),
777 });
778 }
779
780 Ok(TickerRequest {
781 instrument_type: DeriveInstrumentType::Erc20,
782 currency,
783 expiry_date: None,
784 })
785}
786
787fn build_client(
788 timeout_secs: u64,
789 proxy_url: Option<String>,
790) -> std::result::Result<(HttpClient, Arc<DeriveRateLimiter>), HttpClientError> {
791 let rate_limiter = Arc::new(FixedWindowLimiter::new(
795 rate_limit::FixedWindowLimits::rest(None, None),
796 MonotonicClock {},
797 ));
798 let client = HttpClient::builder()
802 .redirect_policy(HttpRedirectPolicy::Reject)
803 .headers(create_standard_nautilus_headers().into_iter().collect())
804 .timeout_secs(timeout_secs)
805 .maybe_proxy_url(proxy_url)
806 .rate_limiters(Vec::new())
807 .build()?;
808 Ok((client, rate_limiter))
809}
810
811fn trim_trailing_slash(url: String) -> String {
812 if url.ends_with('/') {
813 url.trim_end_matches('/').to_string()
814 } else {
815 url
816 }
817}
818
819fn decode_envelope<R: DeserializeOwned>(
820 method: &str,
821 request_id: u64,
822 response: HttpResponse,
823) -> Result<R> {
824 let status = response.status.as_u16();
825 let is_success_status = (200..300).contains(&status);
826 let body = response.body;
827
828 let envelope: JsonRpcResponse<R> = match serde_json::from_slice(&body) {
829 Ok(env) => env,
830 Err(e) => {
831 if !is_success_status {
832 let text = String::from_utf8_lossy(&body).into_owned();
833 return Err(DeriveHttpError::http(status, truncate(text, 512)));
834 }
835 return Err(DeriveHttpError::decode(format!(
836 "failed to decode `{method}` response: {e}",
837 )));
838 }
839 };
840
841 if let Some(err) = envelope.error {
842 return Err(DeriveHttpError::JsonRpc {
843 code: err.code,
844 message: err.message,
845 data: err.data,
846 });
847 }
848
849 if !is_success_status {
854 let text = String::from_utf8_lossy(&body).into_owned();
855 return Err(DeriveHttpError::http(status, truncate(text, 512)));
856 }
857
858 if let Some(echoed) = envelope.id
859 && echoed != request_id
860 {
861 log::debug!(
862 "derive: id mismatch for `{method}` (sent={request_id}, recv={echoed}); accepting result",
863 );
864 }
865
866 envelope
867 .result
868 .ok_or_else(|| DeriveHttpError::MissingResult {
869 method: method.to_owned(),
870 })
871}
872
873fn truncate(s: String, max: usize) -> String {
874 if s.len() <= max {
875 return s;
876 }
877 let mut cutoff = max;
878 while cutoff > 0 && !s.is_char_boundary(cutoff) {
879 cutoff -= 1;
880 }
881 let mut out = String::with_capacity(cutoff + 3);
882 out.push_str(&s[..cutoff]);
883 out.push_str("...");
884 out
885}
886
887#[cfg(test)]
888mod tests {
889 use std::collections::HashMap;
890
891 use nautilus_network::http::{HttpStatus, StatusCode};
892 use nautilus_testkit::http::assert_http_redirect_rejected;
893 use rstest::rstest;
894
895 use super::*;
896
897 const SESSION_KEY_HEX: &str =
898 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
899 const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
900
901 fn test_client() -> DeriveHttpClient {
902 DeriveHttpClient::new("https://api.example/", None, None, None).expect("client builds")
903 }
904
905 fn test_response(status: u16, body: &serde_json::Value) -> HttpResponse {
906 let status_code = StatusCode::from_u16(status).unwrap();
907 HttpResponse {
908 status: HttpStatus::new(status_code),
909 headers: HashMap::new(),
910 body: serde_json::to_vec(body).unwrap().into(),
911 }
912 }
913
914 #[tokio::test]
915 async fn test_authenticated_client_rejects_redirects() {
916 let client = build_client(3, None).unwrap().0;
917 assert_http_redirect_rejected(|url| async move {
918 client
919 .get(url, None, None, Some(3), None)
920 .await
921 .unwrap()
922 .status
923 .as_u16()
924 })
925 .await;
926 }
927
928 #[rstest]
929 fn test_credentials_debug_redacts_signer() {
930 let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
931 let dbg = format!("{creds:?}");
932 assert!(dbg.contains(REDACTED));
933 assert!(dbg.contains(TEST_WALLET));
934 assert!(!dbg.contains(SESSION_KEY_HEX));
935 }
936
937 #[rstest]
938 fn test_credentials_rejects_invalid_session_key() {
939 let err = DeriveCredentials::new(TEST_WALLET, "not-hex").expect_err("must reject");
940 match err {
941 DeriveHttpError::Decode(msg) => assert!(msg.contains("invalid session key")),
942 other => panic!("expected Decode, was {other:?}"),
943 }
944 }
945
946 #[rstest]
947 fn test_base_url_trims_trailing_slash() {
948 let client = test_client();
949 assert_eq!(client.base_url(), "https://api.example");
950 }
951
952 #[rstest]
953 fn test_new_has_no_credentials() {
954 assert!(!test_client().has_credentials());
955 }
956
957 #[rstest]
958 fn test_with_credentials_sets_creds() {
959 let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
960 let client =
961 DeriveHttpClient::with_credentials("https://api.example", creds, None, None, None)
962 .unwrap();
963 assert!(client.has_credentials());
964 }
965
966 #[rstest]
967 fn test_next_id_increments_monotonically() {
968 let client = test_client();
969 let a = client.next_id();
970 let b = client.next_id();
971 let c = client.next_id();
972 assert_eq!(b, a + 1);
973 assert_eq!(c, b + 1);
974 }
975
976 #[rstest]
977 fn test_decode_envelope_returns_result() {
978 let resp = test_response(200, &serde_json::json!({"id": 1, "result": {"ok": true}}));
979 let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
980 assert_eq!(value["ok"], true);
981 }
982
983 #[rstest]
984 fn test_decode_envelope_accepts_null_empty_result() {
985 let resp = test_response(200, &serde_json::json!({"id": 1, "result": null}));
986 let result: DeriveEmptyResult = decode_envelope("private/cancel", 1, resp).unwrap();
987 assert_eq!(result, DeriveEmptyResult {});
988 }
989
990 #[rstest]
991 fn test_decode_envelope_propagates_jsonrpc_error() {
992 let resp = test_response(
993 200,
994 &serde_json::json!({
995 "id": 1,
996 "error": {"code": -32601, "message": "Method not found"}
997 }),
998 );
999 let err: DeriveHttpError = decode_envelope::<Value>("public/missing", 1, resp).unwrap_err();
1000 match err {
1001 DeriveHttpError::JsonRpc { code, message, .. } => {
1002 assert_eq!(code, -32601);
1003 assert_eq!(message, "Method not found");
1004 }
1005 other => panic!("expected JsonRpc, was {other:?}"),
1006 }
1007 }
1008
1009 #[rstest]
1010 fn test_decode_envelope_flags_missing_result() {
1011 let resp = test_response(200, &serde_json::json!({"id": 1}));
1012 let err = decode_envelope::<Value>("public/get_instruments", 1, resp).unwrap_err();
1013 assert!(matches!(err, DeriveHttpError::MissingResult { .. }));
1014 }
1015
1016 #[rstest]
1017 fn test_decode_envelope_flags_non_2xx_with_unparsable_body() {
1018 let status_code = StatusCode::from_u16(503).unwrap();
1019 let response = HttpResponse {
1020 status: HttpStatus::new(status_code),
1021 headers: HashMap::new(),
1022 body: bytes::Bytes::from_static(b"<html>upstream down</html>"),
1023 };
1024 let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1025 match err {
1026 DeriveHttpError::Http { status, message } => {
1027 assert_eq!(status, 503);
1028 assert!(message.contains("upstream down"));
1029 }
1030 other => panic!("expected Http, was {other:?}"),
1031 }
1032 }
1033
1034 #[rstest]
1035 fn test_decode_envelope_flags_non_2xx_with_non_envelope_json() {
1036 let resp = test_response(401, &serde_json::json!({"message": "Unauthorized"}));
1039 let err = decode_envelope::<Value>("private/order", 1, resp).unwrap_err();
1040 match err {
1041 DeriveHttpError::Http { status, message } => {
1042 assert_eq!(status, 401);
1043 assert!(message.contains("Unauthorized"));
1044 }
1045 other => panic!("expected Http, was {other:?}"),
1046 }
1047 }
1048
1049 #[rstest]
1050 fn test_decode_envelope_prefers_jsonrpc_error_over_http_status() {
1051 let status_code = StatusCode::from_u16(400).unwrap();
1054 let body = serde_json::json!({
1055 "id": 1,
1056 "error": {"code": -32602, "message": "Invalid params"},
1057 });
1058 let response = HttpResponse {
1059 status: HttpStatus::new(status_code),
1060 headers: HashMap::new(),
1061 body: serde_json::to_vec(&body).unwrap().into(),
1062 };
1063 let err = decode_envelope::<Value>("private/order", 1, response).unwrap_err();
1064 assert!(matches!(err, DeriveHttpError::JsonRpc { code: -32602, .. }));
1065 }
1066
1067 #[rstest]
1068 fn test_truncate_handles_multi_byte_char_at_boundary() {
1069 let s = "ΩΩΩΩΩΩΩΩΩΩ".to_string();
1072 assert_eq!(s.len(), 20);
1073 let out = truncate(s, 5);
1074 assert!(out.ends_with("..."));
1075 let prefix = out.trim_end_matches("...");
1076 assert!(prefix.is_char_boundary(prefix.len()));
1077 assert!(prefix.chars().all(|c| c == 'Ω'));
1078 }
1079
1080 #[rstest]
1081 fn test_truncate_returns_input_when_under_limit() {
1082 let s = "short".to_string();
1083 assert_eq!(truncate(s, 16), "short");
1084 }
1085
1086 #[rstest]
1087 fn test_decode_envelope_non_2xx_body_with_non_ascii_does_not_panic() {
1088 let glyph = "Ω";
1091 let body = glyph.repeat(600);
1092 let status_code = StatusCode::from_u16(503).unwrap();
1093 let response = HttpResponse {
1094 status: HttpStatus::new(status_code),
1095 headers: HashMap::new(),
1096 body: body.into_bytes().into(),
1097 };
1098 let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1099 assert!(matches!(err, DeriveHttpError::Http { status: 503, .. }));
1100 }
1101
1102 #[rstest]
1103 fn test_decode_envelope_accepts_id_mismatch() {
1104 let resp = test_response(200, &serde_json::json!({"id": 99, "result": "ok"}));
1105 let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
1106 assert_eq!(value, serde_json::json!("ok"));
1107 }
1108
1109 #[tokio::test]
1110 async fn test_send_private_without_credentials_errors() {
1111 let client = test_client();
1112 let err = client
1113 .send_private::<_, Value>("private/order", &serde_json::json!({}))
1114 .await
1115 .expect_err("must require credentials");
1116
1117 match err {
1118 DeriveHttpError::MissingCredentials { method } => {
1119 assert_eq!(method, "private/order");
1120 }
1121 other => panic!("expected MissingCredentials, was {other:?}"),
1122 }
1123 }
1124}