1use std::{
25 collections::HashMap,
26 fmt::Debug,
27 sync::{
28 Arc,
29 atomic::{AtomicU64, Ordering},
30 },
31};
32
33use ahash::AHashMap;
34use alloy::signers::local::PrivateKeySigner;
35use nautilus_network::{
36 http::{HttpClient, HttpClientError, HttpResponse},
37 retry::{RetryConfig, RetryManager},
38};
39use serde::{Serialize, de::DeserializeOwned};
40use serde_json::Value;
41
42use crate::{
43 common::{
44 consts::{HEADER_LYRA_SIGNATURE, HEADER_LYRA_TIMESTAMP, HEADER_LYRA_WALLET, HTTP_TIMEOUT},
45 enums::DeriveInstrumentType,
46 rate_limit::{self, DERIVE_NON_MATCHING_RATE_KEY},
47 retry::{http_retry_config, should_retry_http_error},
48 },
49 http::{
50 error::{DeriveHttpError, Result},
51 models::{
52 DeriveEmptyResult, DeriveInstrument, DeriveOpenOrdersResult, DeriveOrder,
53 DeriveOrderResult, DeriveOrdersResult, DerivePositionsResult, DerivePublicCandle,
54 DerivePublicFundingRateHistoryResult, DerivePublicTradesResult, DeriveReplaceResult,
55 DeriveSubaccount, DeriveTickerSnapshot, DeriveTickersResult, DeriveTradesResult,
56 JsonRpcResponse,
57 },
58 query::{
59 DeriveCancelAllParams, DeriveCancelByLabelParams, DeriveCancelParams,
60 DeriveGetOpenOrdersParams, DeriveGetOrderHistoryParams, DeriveGetOrderParams,
61 DeriveGetPositionsParams, DeriveGetSubaccountParams, DeriveGetTradeHistoryParams,
62 DeriveGetTriggerOrdersParams, DeriveOrderParams, DeriveReplaceParams,
63 },
64 },
65 signing::auth::{AuthHeaders, build_rest_auth_headers},
66};
67
68#[derive(Clone)]
73pub struct DeriveCredentials {
74 pub wallet_address: String,
76 pub signer: PrivateKeySigner,
78}
79
80impl DeriveCredentials {
81 pub fn new(wallet_address: impl Into<String>, session_key_hex: &str) -> Result<Self> {
88 let signer: PrivateKeySigner = session_key_hex
89 .parse()
90 .map_err(|e| DeriveHttpError::decode(format!("invalid session key: {e}")))?;
91 Ok(Self {
92 wallet_address: wallet_address.into(),
93 signer,
94 })
95 }
96}
97
98impl Debug for DeriveCredentials {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.debug_struct(stringify!(DeriveCredentials))
101 .field("wallet_address", &self.wallet_address)
102 .field("signer", &"***redacted***")
103 .finish()
104 }
105}
106
107#[derive(Debug, Clone)]
115pub struct DeriveHttpClient {
116 client: HttpClient,
117 base_url: String,
118 credentials: Option<DeriveCredentials>,
119 next_id: Arc<AtomicU64>,
120 timeout_secs: u64,
121 retry_manager: Arc<RetryManager<DeriveHttpError>>,
122}
123
124impl DeriveHttpClient {
125 pub fn new(
134 base_url: impl Into<String>,
135 timeout_secs: Option<u64>,
136 proxy_url: Option<String>,
137 retry_config: Option<RetryConfig>,
138 ) -> Result<Self> {
139 let timeout_secs = timeout_secs.unwrap_or_else(|| HTTP_TIMEOUT.as_secs());
140 let client = build_client(timeout_secs, proxy_url)?;
141 let retry_config = retry_config.unwrap_or_else(|| http_retry_config(3, 100, 5_000));
142 Ok(Self {
143 client,
144 base_url: trim_trailing_slash(base_url.into()),
145 credentials: None,
146 next_id: Arc::new(AtomicU64::new(1)),
147 timeout_secs,
148 retry_manager: Arc::new(RetryManager::new(retry_config)),
149 })
150 }
151
152 pub fn with_credentials(
159 base_url: impl Into<String>,
160 credentials: DeriveCredentials,
161 timeout_secs: Option<u64>,
162 proxy_url: Option<String>,
163 retry_config: Option<RetryConfig>,
164 ) -> Result<Self> {
165 let mut client = Self::new(base_url, timeout_secs, proxy_url, retry_config)?;
166 client.credentials = Some(credentials);
167 Ok(client)
168 }
169
170 #[must_use]
172 pub fn base_url(&self) -> &str {
173 &self.base_url
174 }
175
176 #[must_use]
178 pub fn has_credentials(&self) -> bool {
179 self.credentials.is_some()
180 }
181
182 fn next_id(&self) -> u64 {
184 self.next_id.fetch_add(1, Ordering::Relaxed)
185 }
186
187 pub async fn send_public<P, R>(&self, method: &str, params: &P) -> Result<R>
196 where
197 P: Serialize + ?Sized,
198 R: DeserializeOwned,
199 {
200 let id = self.next_id();
201 self.dispatch(method, params, id, false, true).await
202 }
203
204 pub async fn send_private<P, R>(&self, method: &str, params: &P) -> Result<R>
216 where
217 P: Serialize + ?Sized,
218 R: DeserializeOwned,
219 {
220 if self.credentials.is_none() {
221 return Err(DeriveHttpError::MissingCredentials {
222 method: method.to_owned(),
223 });
224 }
225 let id = self.next_id();
226 self.dispatch(method, params, id, true, true).await
227 }
228
229 pub async fn send_private_once<P, R>(&self, method: &str, params: &P) -> Result<R>
247 where
248 P: Serialize + ?Sized,
249 R: DeserializeOwned,
250 {
251 if self.credentials.is_none() {
252 return Err(DeriveHttpError::MissingCredentials {
253 method: method.to_owned(),
254 });
255 }
256 let id = self.next_id();
257 self.dispatch(method, params, id, true, false).await
258 }
259
260 pub async fn get_instruments(
269 &self,
270 currency: &str,
271 instrument_type: DeriveInstrumentType,
272 expired: bool,
273 ) -> Result<Vec<DeriveInstrument>> {
274 let params = serde_json::json!({
275 "currency": currency,
276 "instrument_type": instrument_type,
277 "expired": expired,
278 });
279 self.send_public("public/get_instruments", ¶ms).await
280 }
281
282 pub async fn get_instrument(&self, instrument_name: &str) -> Result<DeriveInstrument> {
292 let params = serde_json::json!({
293 "instrument_name": instrument_name,
294 });
295 self.send_public("public/get_instrument", ¶ms).await
296 }
297
298 pub async fn get_trade_history(
308 &self,
309 instrument_name: &str,
310 from_timestamp: Option<i64>,
311 to_timestamp: Option<i64>,
312 page: u32,
313 page_size: u32,
314 ) -> Result<DerivePublicTradesResult> {
315 let mut params = serde_json::Map::new();
316 params.insert("instrument_name".to_string(), instrument_name.into());
317 params.insert("page".to_string(), page.into());
318 params.insert("page_size".to_string(), page_size.into());
319 if let Some(from) = from_timestamp {
320 params.insert("from_timestamp".to_string(), from.into());
321 }
322
323 if let Some(to) = to_timestamp {
324 params.insert("to_timestamp".to_string(), to.into());
325 }
326
327 self.send_public("public/get_trade_history", &Value::Object(params))
328 .await
329 }
330
331 pub async fn get_funding_rate_history(
340 &self,
341 instrument_name: &str,
342 start_timestamp: Option<i64>,
343 end_timestamp: Option<i64>,
344 period: Option<u32>,
345 ) -> Result<DerivePublicFundingRateHistoryResult> {
346 let mut params = serde_json::Map::new();
347 params.insert("instrument_name".to_string(), instrument_name.into());
348 if let Some(start) = start_timestamp {
349 params.insert("start_timestamp".to_string(), start.into());
350 }
351
352 if let Some(end) = end_timestamp {
353 params.insert("end_timestamp".to_string(), end.into());
354 }
355
356 if let Some(period) = period {
357 params.insert("period".to_string(), period.into());
358 }
359
360 self.send_public("public/get_funding_rate_history", &Value::Object(params))
361 .await
362 }
363
364 pub async fn get_candles(
376 &self,
377 instrument_name: &str,
378 start_timestamp: i64,
379 end_timestamp: i64,
380 period: u32,
381 ) -> Result<Vec<DerivePublicCandle>> {
382 let params = serde_json::json!({
383 "instrument_name": instrument_name,
384 "start_timestamp": start_timestamp,
385 "end_timestamp": end_timestamp,
386 "period": period,
387 });
388 self.send_public("public/get_tradingview_chart_data", ¶ms)
389 .await
390 }
391
392 pub async fn get_tickers(
402 &self,
403 instrument_type: DeriveInstrumentType,
404 currency: Option<&str>,
405 expiry_date: Option<&str>,
406 ) -> Result<DeriveTickersResult> {
407 let mut params = serde_json::Map::new();
408 params.insert(
409 "instrument_type".to_string(),
410 serde_json::to_value(instrument_type).map_err(DeriveHttpError::from)?,
411 );
412
413 if let Some(currency) = currency {
414 params.insert("currency".to_string(), currency.into());
415 }
416
417 if let Some(expiry_date) = expiry_date {
418 params.insert("expiry_date".to_string(), expiry_date.into());
419 }
420
421 self.send_public("public/get_tickers", &Value::Object(params))
422 .await
423 }
424
425 pub async fn get_ticker(&self, instrument_name: &str) -> Result<DeriveTickerSnapshot> {
436 let request = ticker_request(instrument_name)?;
437 let result = self
438 .get_tickers(
439 request.instrument_type,
440 Some(request.currency),
441 request.expiry_date,
442 )
443 .await?;
444 let mut ticker = result
445 .tickers
446 .get(instrument_name)
447 .cloned()
448 .ok_or_else(|| {
449 DeriveHttpError::decode(format!(
450 "missing ticker `{instrument_name}` in public/get_tickers response"
451 ))
452 })?;
453 ticker.instrument_name = instrument_name.into();
454 Ok(ticker)
455 }
456
457 pub async fn submit_order(&self, params: &DeriveOrderParams) -> Result<DeriveOrder> {
466 let result: DeriveOrderResult = self.send_private_once("private/order", params).await?;
467 Ok(result.order)
468 }
469
470 pub async fn cancel_order(&self, params: &DeriveCancelParams) -> Result<DeriveEmptyResult> {
477 self.send_private_once("private/cancel", params).await
478 }
479
480 pub async fn cancel_all(&self, params: &DeriveCancelAllParams) -> Result<DeriveEmptyResult> {
488 self.send_private_once("private/cancel_all", params).await
489 }
490
491 pub async fn cancel_by_label(
498 &self,
499 params: &DeriveCancelByLabelParams,
500 ) -> Result<DeriveEmptyResult> {
501 self.send_private_once("private/cancel_by_label", params)
502 .await
503 }
504
505 pub async fn replace_order(&self, params: &DeriveReplaceParams) -> Result<DeriveOrder> {
515 let result: DeriveReplaceResult = self.send_private_once("private/replace", params).await?;
516 Ok(result.order)
517 }
518
519 pub async fn get_subaccount(
527 &self,
528 params: &DeriveGetSubaccountParams,
529 ) -> Result<DeriveSubaccount> {
530 self.send_private("private/get_subaccount", params).await
531 }
532
533 pub async fn get_open_orders(
540 &self,
541 params: &DeriveGetOpenOrdersParams,
542 ) -> Result<DeriveOpenOrdersResult> {
543 self.send_private("private/get_open_orders", params).await
544 }
545
546 pub async fn get_trigger_orders(
553 &self,
554 params: &DeriveGetTriggerOrdersParams,
555 ) -> Result<DeriveOpenOrdersResult> {
556 self.send_private("private/get_trigger_orders", params)
557 .await
558 }
559
560 pub async fn get_order(&self, params: &DeriveGetOrderParams) -> Result<DeriveOrder> {
567 self.send_private("private/get_order", params).await
568 }
569
570 pub async fn get_order_history(
581 &self,
582 params: &DeriveGetOrderHistoryParams,
583 ) -> Result<DeriveOrdersResult> {
584 self.send_private("private/get_order_history", params).await
585 }
586
587 pub async fn get_private_trade_history(
594 &self,
595 params: &DeriveGetTradeHistoryParams,
596 ) -> Result<DeriveTradesResult> {
597 self.send_private("private/get_trade_history", params).await
598 }
599
600 pub async fn get_positions(
607 &self,
608 params: &DeriveGetPositionsParams,
609 ) -> Result<DerivePositionsResult> {
610 self.send_private("private/get_positions", params).await
611 }
612
613 async fn dispatch<P, R>(
614 &self,
615 method: &str,
616 params: &P,
617 id: u64,
618 authenticate: bool,
619 retry: bool,
620 ) -> Result<R>
621 where
622 P: Serialize + ?Sized,
623 R: DeserializeOwned,
624 {
625 let url = format!("{}/{}", self.base_url, method.trim_start_matches('/'));
626 let body_value = serde_json::to_value(params).map_err(DeriveHttpError::from)?;
627 let body = serde_json::to_vec(&body_value).map_err(DeriveHttpError::from)?;
628
629 let rate_keys = vec![DERIVE_NON_MATCHING_RATE_KEY.to_string()];
633
634 let attempt = || async {
638 let mut headers: AHashMap<String, String> = AHashMap::with_capacity(4);
639 headers.insert("Content-Type".to_string(), "application/json".to_string());
640
641 if authenticate {
642 let auth = self.build_auth_headers(method)?;
643 headers.insert(HEADER_LYRA_WALLET.to_string(), auth.wallet);
644 headers.insert(HEADER_LYRA_TIMESTAMP.to_string(), auth.timestamp);
645 headers.insert(HEADER_LYRA_SIGNATURE.to_string(), auth.signature);
646 }
647
648 let response = self
649 .client
650 .post(
651 url.clone(),
652 None,
653 Some(headers.into_iter().collect()),
654 Some(body.clone()),
655 Some(self.timeout_secs),
656 Some(rate_keys.clone()),
657 )
658 .await
659 .map_err(DeriveHttpError::from)?;
660
661 decode_envelope(method, id, response)
662 };
663
664 if retry {
665 self.retry_manager
666 .execute_with_retry(method, attempt, should_retry_http_error, |msg| {
667 DeriveHttpError::transport(msg)
668 })
669 .await
670 } else {
671 attempt().await
672 }
673 }
674
675 fn build_auth_headers(&self, method: &str) -> Result<AuthHeaders> {
676 let credentials =
677 self.credentials
678 .as_ref()
679 .ok_or_else(|| DeriveHttpError::MissingCredentials {
680 method: method.to_owned(),
681 })?;
682 let auth = build_rest_auth_headers(&credentials.wallet_address, &credentials.signer)?;
683 Ok(auth)
684 }
685}
686
687#[derive(Debug, Clone, Copy)]
688struct TickerRequest<'a> {
689 instrument_type: DeriveInstrumentType,
690 currency: &'a str,
691 expiry_date: Option<&'a str>,
692}
693
694fn ticker_request(instrument_name: &str) -> Result<TickerRequest<'_>> {
695 let Some((currency, suffix)) = instrument_name.split_once('-') else {
696 return Err(DeriveHttpError::decode(format!(
697 "invalid Derive instrument name `{instrument_name}`"
698 )));
699 };
700
701 if suffix == "PERP" {
702 return Ok(TickerRequest {
703 instrument_type: DeriveInstrumentType::Perp,
704 currency,
705 expiry_date: None,
706 });
707 }
708
709 let mut parts = suffix.split('-');
710 let Some(expiry_date) = parts.next() else {
711 return Ok(TickerRequest {
712 instrument_type: DeriveInstrumentType::Erc20,
713 currency,
714 expiry_date: None,
715 });
716 };
717 let has_option_tail = parts.clone().count() == 2;
718 if expiry_date.len() == 8 && expiry_date.chars().all(|c| c.is_ascii_digit()) && has_option_tail
719 {
720 return Ok(TickerRequest {
721 instrument_type: DeriveInstrumentType::Option,
722 currency,
723 expiry_date: Some(expiry_date),
724 });
725 }
726
727 Ok(TickerRequest {
728 instrument_type: DeriveInstrumentType::Erc20,
729 currency,
730 expiry_date: None,
731 })
732}
733
734fn build_client(
735 timeout_secs: u64,
736 proxy_url: Option<String>,
737) -> std::result::Result<HttpClient, HttpClientError> {
738 HttpClient::new(
742 HashMap::new(),
743 Vec::new(),
744 Vec::new(),
745 Some(rate_limit::non_matching_quota()),
746 Some(timeout_secs),
747 proxy_url,
748 )
749}
750
751fn trim_trailing_slash(url: String) -> String {
752 if url.ends_with('/') {
753 url.trim_end_matches('/').to_string()
754 } else {
755 url
756 }
757}
758
759fn decode_envelope<R: DeserializeOwned>(
760 method: &str,
761 request_id: u64,
762 response: HttpResponse,
763) -> Result<R> {
764 let status = response.status.as_u16();
765 let is_success_status = (200..300).contains(&status);
766 let body = response.body;
767
768 let envelope: JsonRpcResponse<R> = match serde_json::from_slice(&body) {
769 Ok(env) => env,
770 Err(e) => {
771 if !is_success_status {
772 let text = String::from_utf8_lossy(&body).into_owned();
773 return Err(DeriveHttpError::http(status, truncate(text, 512)));
774 }
775 return Err(DeriveHttpError::decode(format!(
776 "failed to decode `{method}` response: {e}",
777 )));
778 }
779 };
780
781 if let Some(err) = envelope.error {
782 return Err(DeriveHttpError::JsonRpc {
783 code: err.code,
784 message: err.message,
785 data: err.data,
786 });
787 }
788
789 if !is_success_status {
794 let text = String::from_utf8_lossy(&body).into_owned();
795 return Err(DeriveHttpError::http(status, truncate(text, 512)));
796 }
797
798 if let Some(echoed) = envelope.id
799 && echoed != request_id
800 {
801 log::debug!(
802 "derive: id mismatch for `{method}` (sent={request_id}, recv={echoed}); accepting result",
803 );
804 }
805
806 envelope
807 .result
808 .ok_or_else(|| DeriveHttpError::MissingResult {
809 method: method.to_owned(),
810 })
811}
812
813fn truncate(s: String, max: usize) -> String {
814 if s.len() <= max {
815 return s;
816 }
817 let mut cutoff = max;
818 while cutoff > 0 && !s.is_char_boundary(cutoff) {
819 cutoff -= 1;
820 }
821 let mut out = String::with_capacity(cutoff + 3);
822 out.push_str(&s[..cutoff]);
823 out.push_str("...");
824 out
825}
826
827#[cfg(test)]
828mod tests {
829 use nautilus_network::http::{HttpStatus, StatusCode};
830 use rstest::rstest;
831
832 use super::*;
833
834 const SESSION_KEY_HEX: &str =
835 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
836 const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
837
838 fn test_client() -> DeriveHttpClient {
839 DeriveHttpClient::new("https://api.example/", None, None, None).expect("client builds")
840 }
841
842 fn test_response(status: u16, body: &serde_json::Value) -> HttpResponse {
843 let status_code = StatusCode::from_u16(status).unwrap();
844 HttpResponse {
845 status: HttpStatus::new(status_code),
846 headers: HashMap::new(),
847 body: serde_json::to_vec(body).unwrap().into(),
848 }
849 }
850
851 #[rstest]
852 fn test_credentials_debug_redacts_signer() {
853 let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
854 let dbg = format!("{creds:?}");
855 assert!(dbg.contains("***redacted***"));
856 assert!(dbg.contains(TEST_WALLET));
857 assert!(!dbg.contains(SESSION_KEY_HEX));
858 }
859
860 #[rstest]
861 fn test_credentials_rejects_invalid_session_key() {
862 let err = DeriveCredentials::new(TEST_WALLET, "not-hex").expect_err("must reject");
863 match err {
864 DeriveHttpError::Decode(msg) => assert!(msg.contains("invalid session key")),
865 other => panic!("expected Decode, was {other:?}"),
866 }
867 }
868
869 #[rstest]
870 fn test_base_url_trims_trailing_slash() {
871 let client = test_client();
872 assert_eq!(client.base_url(), "https://api.example");
873 }
874
875 #[rstest]
876 fn test_new_has_no_credentials() {
877 assert!(!test_client().has_credentials());
878 }
879
880 #[rstest]
881 fn test_with_credentials_sets_creds() {
882 let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
883 let client =
884 DeriveHttpClient::with_credentials("https://api.example", creds, None, None, None)
885 .unwrap();
886 assert!(client.has_credentials());
887 }
888
889 #[rstest]
890 fn test_next_id_increments_monotonically() {
891 let client = test_client();
892 let a = client.next_id();
893 let b = client.next_id();
894 let c = client.next_id();
895 assert_eq!(b, a + 1);
896 assert_eq!(c, b + 1);
897 }
898
899 #[rstest]
900 fn test_decode_envelope_returns_result() {
901 let resp = test_response(200, &serde_json::json!({"id": 1, "result": {"ok": true}}));
902 let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
903 assert_eq!(value["ok"], true);
904 }
905
906 #[rstest]
907 fn test_decode_envelope_propagates_jsonrpc_error() {
908 let resp = test_response(
909 200,
910 &serde_json::json!({
911 "id": 1,
912 "error": {"code": -32601, "message": "Method not found"}
913 }),
914 );
915 let err: DeriveHttpError = decode_envelope::<Value>("public/missing", 1, resp).unwrap_err();
916 match err {
917 DeriveHttpError::JsonRpc { code, message, .. } => {
918 assert_eq!(code, -32601);
919 assert_eq!(message, "Method not found");
920 }
921 other => panic!("expected JsonRpc, was {other:?}"),
922 }
923 }
924
925 #[rstest]
926 fn test_decode_envelope_flags_missing_result() {
927 let resp = test_response(200, &serde_json::json!({"id": 1}));
928 let err = decode_envelope::<Value>("public/get_instruments", 1, resp).unwrap_err();
929 assert!(matches!(err, DeriveHttpError::MissingResult { .. }));
930 }
931
932 #[rstest]
933 fn test_decode_envelope_flags_non_2xx_with_unparsable_body() {
934 let status_code = StatusCode::from_u16(503).unwrap();
935 let response = HttpResponse {
936 status: HttpStatus::new(status_code),
937 headers: HashMap::new(),
938 body: bytes::Bytes::from_static(b"<html>upstream down</html>"),
939 };
940 let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
941 match err {
942 DeriveHttpError::Http { status, message } => {
943 assert_eq!(status, 503);
944 assert!(message.contains("upstream down"));
945 }
946 other => panic!("expected Http, was {other:?}"),
947 }
948 }
949
950 #[rstest]
951 fn test_decode_envelope_flags_non_2xx_with_non_envelope_json() {
952 let resp = test_response(401, &serde_json::json!({"message": "Unauthorized"}));
955 let err = decode_envelope::<Value>("private/order", 1, resp).unwrap_err();
956 match err {
957 DeriveHttpError::Http { status, message } => {
958 assert_eq!(status, 401);
959 assert!(message.contains("Unauthorized"));
960 }
961 other => panic!("expected Http, was {other:?}"),
962 }
963 }
964
965 #[rstest]
966 fn test_decode_envelope_prefers_jsonrpc_error_over_http_status() {
967 let status_code = StatusCode::from_u16(400).unwrap();
970 let body = serde_json::json!({
971 "id": 1,
972 "error": {"code": -32602, "message": "Invalid params"},
973 });
974 let response = HttpResponse {
975 status: HttpStatus::new(status_code),
976 headers: HashMap::new(),
977 body: serde_json::to_vec(&body).unwrap().into(),
978 };
979 let err = decode_envelope::<Value>("private/order", 1, response).unwrap_err();
980 assert!(matches!(err, DeriveHttpError::JsonRpc { code: -32602, .. }));
981 }
982
983 #[rstest]
984 fn test_truncate_handles_multi_byte_char_at_boundary() {
985 let s = "ΩΩΩΩΩΩΩΩΩΩ".to_string();
988 assert_eq!(s.len(), 20);
989 let out = truncate(s, 5);
990 assert!(out.ends_with("..."));
991 let prefix = out.trim_end_matches("...");
992 assert!(prefix.is_char_boundary(prefix.len()));
993 assert!(prefix.chars().all(|c| c == 'Ω'));
994 }
995
996 #[rstest]
997 fn test_truncate_returns_input_when_under_limit() {
998 let s = "short".to_string();
999 assert_eq!(truncate(s, 16), "short");
1000 }
1001
1002 #[rstest]
1003 fn test_decode_envelope_non_2xx_body_with_non_ascii_does_not_panic() {
1004 let glyph = "Ω";
1007 let body = glyph.repeat(600);
1008 let status_code = StatusCode::from_u16(503).unwrap();
1009 let response = HttpResponse {
1010 status: HttpStatus::new(status_code),
1011 headers: HashMap::new(),
1012 body: body.into_bytes().into(),
1013 };
1014 let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1015 assert!(matches!(err, DeriveHttpError::Http { status: 503, .. }));
1016 }
1017
1018 #[rstest]
1019 fn test_decode_envelope_accepts_id_mismatch() {
1020 let resp = test_response(200, &serde_json::json!({"id": 99, "result": "ok"}));
1021 let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
1022 assert_eq!(value, serde_json::json!("ok"));
1023 }
1024
1025 #[tokio::test]
1026 async fn test_send_private_without_credentials_errors() {
1027 let client = test_client();
1028 let err = client
1029 .send_private::<_, Value>("private/order", &serde_json::json!({}))
1030 .await
1031 .expect_err("must require credentials");
1032
1033 match err {
1034 DeriveHttpError::MissingCredentials { method } => {
1035 assert_eq!(method, "private/order");
1036 }
1037 other => panic!("expected MissingCredentials, was {other:?}"),
1038 }
1039 }
1040}