Skip to main content

nautilus_derive/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! `reqwest`-backed REST client for the Derive API.
17//!
18//! [`DeriveHttpClient`] exposes typed `send_public` / `send_private`
19//! dispatchers plus thin wrappers for the two endpoints that establish the
20//! plumbing this crate needs to grow against: `public/get_instruments` and
21//! `private/order`. Authenticated requests inject the EIP-191 session-key
22//! headers built by [`crate::signing::auth`].
23
24use 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/// Credentials used to sign authenticated REST requests.
69///
70/// `Debug` is implemented manually so the session key never escapes through
71/// loggers or Python `__repr__`.
72#[derive(Clone)]
73pub struct DeriveCredentials {
74    /// Derive Chain smart-contract wallet address (`0x`-prefixed hex, 42 chars).
75    pub wallet_address: String,
76    /// secp256k1 session-key signer.
77    pub signer: PrivateKeySigner,
78}
79
80impl DeriveCredentials {
81    /// Constructs credentials by parsing `session_key_hex` into a signer.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`DeriveHttpError::Auth`] when the session-key hex cannot be
86    /// parsed.
87    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/// HTTP client for the Derive REST API.
108///
109/// The client carries an atomic `id` counter so every request frame has a
110/// unique correlator; the REST transport ships only `params` on the wire but
111/// the id is preserved for logs and reused by the upcoming WebSocket client.
112/// Each call routes through a [`RetryManager`] that re-signs auth headers on
113/// every attempt, so retries never replay a stale `X-LYRATIMESTAMP`.
114#[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    /// Creates a public-only client.
126    ///
127    /// `retry_config` defaults to [`http_retry_config(3, 100, 5_000)`] when `None`.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`DeriveHttpError::Transport`] when the underlying HTTP client
132    /// (proxy URL, TLS init) cannot be constructed.
133    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    /// Creates a client with credentials installed for `send_private` calls.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`DeriveHttpError::Transport`] when the underlying HTTP client
157    /// cannot be constructed.
158    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    /// Returns the configured base URL (no trailing slash).
171    #[must_use]
172    pub fn base_url(&self) -> &str {
173        &self.base_url
174    }
175
176    /// Returns `true` when credentials are installed.
177    #[must_use]
178    pub fn has_credentials(&self) -> bool {
179        self.credentials.is_some()
180    }
181
182    /// Allocates the next correlator id.
183    fn next_id(&self) -> u64 {
184        self.next_id.fetch_add(1, Ordering::Relaxed)
185    }
186
187    /// Sends an unauthenticated request and decodes the JSON-RPC envelope.
188    ///
189    /// Public endpoints are idempotent reads; this path retries transient
190    /// failures via the configured [`RetryManager`].
191    ///
192    /// # Errors
193    ///
194    /// Propagates transport, HTTP, and JSON-RPC errors. See [`DeriveHttpError`].
195    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    /// Sends an authenticated idempotent request (private reads).
205    ///
206    /// Used for `private/get_*` endpoints whose responses are pure reads of
207    /// venue state. Transient failures retry via the configured
208    /// [`RetryManager`].
209    ///
210    /// # Errors
211    ///
212    /// Returns [`DeriveHttpError::MissingCredentials`] when the client was
213    /// built without credentials. Other variants propagate from the transport
214    /// or the venue.
215    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    /// Sends an authenticated request exactly once (no retry).
230    ///
231    /// Used for state-changing endpoints (`private/order`, `private/cancel`,
232    /// `private/cancel_all`, `private/cancel_by_label`, `private/replace`)
233    /// where a transport-level failure leaves the venue's view of the
234    /// signed action ambiguous: the request may have been accepted before
235    /// the network broke. Automatic replay would either double-submit (when
236    /// the venue accepted) or trigger a duplicate-nonce rejection (which
237    /// the caller would surface as `OrderRejected` even though the original
238    /// is live). Callers are expected to resolve ambiguous outcomes via
239    /// reconciliation rather than retry here.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`DeriveHttpError::MissingCredentials`] when the client was
244    /// built without credentials. Other variants propagate from the transport
245    /// or the venue.
246    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    /// Fetches the venue's listed instruments.
261    ///
262    /// `currency` is the perpetual/option underlying (e.g. `"ETH"`). When
263    /// `expired` is `true` the venue includes expired option strikes.
264    ///
265    /// # Errors
266    ///
267    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
268    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", &params).await
280    }
281
282    /// Fetches a single instrument definition by name.
283    ///
284    /// Mirrors `public/get_instrument`, which the venue documents as the
285    /// per-asset variant of `public/get_instruments`. The returned record
286    /// matches one row of the bulk endpoint.
287    ///
288    /// # Errors
289    ///
290    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
291    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", &params).await
296    }
297
298    /// Fetches a page of public trade history for the instrument.
299    ///
300    /// `from_timestamp` / `to_timestamp` are UNIX milliseconds and bound the
301    /// returned window. `page` is 1-indexed; `page_size` is capped by the venue
302    /// at 1000.
303    ///
304    /// # Errors
305    ///
306    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
307    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    /// Fetches the public funding rate history for the instrument.
332    ///
333    /// `start_timestamp` / `end_timestamp` are UNIX milliseconds. `period`, if
334    /// provided, selects the sample interval in seconds.
335    ///
336    /// # Errors
337    ///
338    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
339    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    /// Fetches OHLCV candles via `public/get_tradingview_chart_data`.
365    ///
366    /// `start_timestamp` / `end_timestamp` are UNIX **seconds** and bound the
367    /// returned window. `period` is the bucket size in seconds; the venue
368    /// accepts 60, 300, 900, 1800, 3600, 14400, 28800, 86400, and 604800.
369    /// The venue ships `result` as a flat array; the client decodes it
370    /// directly into `Vec<DerivePublicCandle>`.
371    ///
372    /// # Errors
373    ///
374    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
375    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", &params)
389            .await
390    }
391
392    /// Fetches current ticker snapshots.
393    ///
394    /// `currency` is the underlying (`"ETH"`, `"BTC"`, etc.). Options require
395    /// both `currency` and `expiry_date`; perps and ERC-20 spot pairs reject
396    /// `expiry_date`.
397    ///
398    /// # Errors
399    ///
400    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
401    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    /// Fetches the current ticker snapshot for one instrument.
426    ///
427    /// This is a single-instrument convenience wrapper over
428    /// `public/get_tickers`, which replaced Derive's deprecated
429    /// `public/get_ticker` RPC.
430    ///
431    /// # Errors
432    ///
433    /// Propagates [`DeriveHttpError`] for transport, HTTP, JSON-RPC failures,
434    /// or when the response omits the requested instrument.
435    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    /// Submits a signed order to the venue.
458    ///
459    /// `params` must be the fully-built signed `private/order` body.
460    ///
461    /// # Errors
462    ///
463    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
464    /// were installed; otherwise propagates transport and venue errors.
465    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    /// Cancels a single order by venue order id.
471    ///
472    /// # Errors
473    ///
474    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
475    /// were installed; otherwise propagates transport and venue errors.
476    pub async fn cancel_order(&self, params: &DeriveCancelParams) -> Result<DeriveEmptyResult> {
477        self.send_private_once("private/cancel", params).await
478    }
479
480    /// Cancels every open order on the subaccount, optionally scoped to an
481    /// instrument.
482    ///
483    /// # Errors
484    ///
485    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
486    /// were installed; otherwise propagates transport and venue errors.
487    pub async fn cancel_all(&self, params: &DeriveCancelAllParams) -> Result<DeriveEmptyResult> {
488        self.send_private_once("private/cancel_all", params).await
489    }
490
491    /// Cancels every open order for the given user label on the subaccount.
492    ///
493    /// # Errors
494    ///
495    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
496    /// were installed; otherwise propagates transport and venue errors.
497    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    /// Submits a signed `private/replace` request that atomically cancels one
506    /// order and creates a new one.
507    ///
508    /// `params` must be the fully-built typed request body.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
513    /// were installed; otherwise propagates transport and venue errors.
514    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    /// Returns the subaccount snapshot including margin, balances, and
520    /// open orders.
521    ///
522    /// # Errors
523    ///
524    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
525    /// were installed; otherwise propagates transport and venue errors.
526    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    /// Returns currently open orders for the subaccount.
534    ///
535    /// # Errors
536    ///
537    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
538    /// were installed; otherwise propagates transport and venue errors.
539    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    /// Returns currently untriggered trigger orders for the subaccount.
547    ///
548    /// # Errors
549    ///
550    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
551    /// were installed; otherwise propagates transport and venue errors.
552    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    /// Returns a single order by venue order id.
561    ///
562    /// # Errors
563    ///
564    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
565    /// were installed; otherwise propagates transport and venue errors.
566    pub async fn get_order(&self, params: &DeriveGetOrderParams) -> Result<DeriveOrder> {
567        self.send_private("private/get_order", params).await
568    }
569
570    /// Returns one page of order history for the subaccount, optionally
571    /// scoped to an instrument and time window.
572    ///
573    /// `from_timestamp` / `to_timestamp` are UNIX milliseconds. `page` is
574    /// 1-indexed and `page_size` is capped by the venue at 1000.
575    ///
576    /// # Errors
577    ///
578    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
579    /// were installed; otherwise propagates transport and venue errors.
580    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    /// Returns one page of subaccount trade history.
588    ///
589    /// # Errors
590    ///
591    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
592    /// were installed; otherwise propagates transport and venue errors.
593    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    /// Returns the positions held by the subaccount.
601    ///
602    /// # Errors
603    ///
604    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
605    /// were installed; otherwise propagates transport and venue errors.
606    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        // Every REST call is a non-matching read; gate it on the shared
630        // non-matching quota. A non-empty key is required: the limiter skips
631        // requests sent with no keys even when a default quota is configured.
632        let rate_keys = vec![DERIVE_NON_MATCHING_RATE_KEY.to_string()];
633
634        // Sign per-attempt so the venue never sees a stale `X-LYRATIMESTAMP`
635        // after a long backoff window; single-shot writes still run the
636        // closure once and use freshly built headers.
637        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    // Every REST endpoint this client calls is a non-matching read, so a single
739    // default quota (keyed by `DERIVE_NON_MATCHING_RATE_KEY` at the call site)
740    // covers them; no per-endpoint keyed quotas are needed.
741    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    // Gateways (Cloudflare, the wallet auth proxy) return non-2xx with a JSON body
790    // like {"message": "Unauthorized"} that parses into an empty envelope. Surface
791    // those as Http errors so retry/reconcile logic sees the real status code
792    // instead of MissingResult.
793    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        // Gateways return non-2xx with JSON bodies like {"message": "Unauthorized"}.
953        // These parse as an empty JsonRpcResponse; the status must still surface.
954        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        // When the venue returns a proper JSON-RPC error envelope with a non-2xx
968        // status, the envelope wins because it carries richer venue context.
969        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        // "Ω" is two bytes (0xCE 0xA9). Truncating to a length that lands mid-glyph
986        // must not panic; we step back to the prior char boundary.
987        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        // Regression: a Cloudflare-style 503 page containing non-ASCII bytes near
1005        // the truncation cutoff must not panic.
1006        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}