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    fmt::Debug,
26    sync::{
27        Arc,
28        atomic::{AtomicU64, Ordering},
29    },
30};
31
32use ahash::AHashMap;
33use alloy::signers::local::PrivateKeySigner;
34use nautilus_network::{
35    http::{HttpClient, HttpClientError, HttpResponse},
36    ratelimiter::clock::MonotonicClock,
37    retry::{RetryConfig, RetryManager},
38};
39use serde::{Serialize, de::DeserializeOwned};
40use serde_json::Value;
41use ustr::Ustr;
42
43use crate::{
44    common::{
45        consts::{HEADER_LYRA_SIGNATURE, HEADER_LYRA_TIMESTAMP, HEADER_LYRA_WALLET, HTTP_TIMEOUT},
46        enums::DeriveInstrumentType,
47        rate_limit::{self, DeriveRateLimiter, FixedWindowLimiter},
48        retry::{http_retry_config, should_retry_http_error},
49    },
50    http::{
51        error::{DeriveHttpError, Result},
52        models::{
53            DeriveCancelByLabelResult, DeriveEmptyResult, DeriveInstrument, DeriveOpenOrdersResult,
54            DeriveOrder, DeriveOrderResult, DeriveOrdersResult, DerivePositionsResult,
55            DerivePublicCandle, DerivePublicFundingRateHistoryResult, DerivePublicTradesResult,
56            DeriveReplaceOutcome, DeriveReplaceResult, DeriveSubaccount, DeriveTickerSnapshot,
57            DeriveTickersResult, DeriveTradesResult, JsonRpcResponse,
58        },
59        query::{
60            DeriveCancelAllParams, DeriveCancelByLabelParams, DeriveCancelParams,
61            DeriveGetOpenOrdersParams, DeriveGetOrderHistoryParams, DeriveGetOrderParams,
62            DeriveGetPositionsParams, DeriveGetSubaccountParams, DeriveGetTradeHistoryParams,
63            DeriveGetTriggerOrdersParams, DeriveOrderParams, DeriveReplaceParams,
64        },
65    },
66    signing::auth::{AuthHeaders, build_rest_auth_headers},
67};
68
69/// Credentials used to sign authenticated REST requests.
70///
71/// `Debug` is implemented manually so the session key never escapes through
72/// loggers or Python `__repr__`.
73#[derive(Clone)]
74pub struct DeriveCredentials {
75    /// Derive Chain smart-contract wallet address (`0x`-prefixed hex, 42 chars).
76    pub wallet_address: String,
77    /// secp256k1 session-key signer.
78    pub signer: PrivateKeySigner,
79}
80
81impl DeriveCredentials {
82    /// Constructs credentials by parsing `session_key_hex` into a signer.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`DeriveHttpError::Auth`] when the session-key hex cannot be
87    /// parsed.
88    pub fn new(wallet_address: impl Into<String>, session_key_hex: &str) -> Result<Self> {
89        let signer: PrivateKeySigner = session_key_hex
90            .parse()
91            .map_err(|e| DeriveHttpError::decode(format!("invalid session key: {e}")))?;
92        Ok(Self {
93            wallet_address: wallet_address.into(),
94            signer,
95        })
96    }
97}
98
99impl Debug for DeriveCredentials {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct(stringify!(DeriveCredentials))
102            .field("wallet_address", &self.wallet_address)
103            .field("signer", &"***redacted***")
104            .finish()
105    }
106}
107
108/// HTTP client for the Derive REST API.
109///
110/// The client carries an atomic `id` counter so every request frame has a
111/// unique correlator; the REST transport ships only `params` on the wire but
112/// the id is preserved for logs and reused by the upcoming WebSocket client.
113/// Each call routes through a [`RetryManager`] that re-signs auth headers on
114/// every attempt, so retries never replay a stale `X-LYRATIMESTAMP`.
115#[derive(Debug, Clone)]
116pub struct DeriveHttpClient {
117    client: HttpClient,
118    base_url: String,
119    credentials: Option<DeriveCredentials>,
120    next_id: Arc<AtomicU64>,
121    timeout_secs: u64,
122    retry_manager: Arc<RetryManager<DeriveHttpError>>,
123    rate_limiter: Arc<DeriveRateLimiter>,
124}
125
126impl DeriveHttpClient {
127    /// Creates a public-only client.
128    ///
129    /// `retry_config` defaults to [`http_retry_config(3, 100, 5_000)`] when `None`.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`DeriveHttpError::Transport`] when the underlying HTTP client
134    /// (proxy URL, TLS init) cannot be constructed.
135    pub fn new(
136        base_url: impl Into<String>,
137        timeout_secs: Option<u64>,
138        proxy_url: Option<String>,
139        retry_config: Option<RetryConfig>,
140    ) -> Result<Self> {
141        let timeout_secs = timeout_secs.unwrap_or_else(|| HTTP_TIMEOUT.as_secs());
142        let (client, rate_limiter) = build_client(timeout_secs, proxy_url)?;
143        let retry_config = retry_config.unwrap_or_else(|| http_retry_config(3, 100, 5_000));
144        Ok(Self {
145            client,
146            base_url: trim_trailing_slash(base_url.into()),
147            credentials: None,
148            next_id: Arc::new(AtomicU64::new(1)),
149            timeout_secs,
150            retry_manager: Arc::new(RetryManager::new(retry_config)),
151            rate_limiter,
152        })
153    }
154
155    /// Creates a client with credentials installed for `send_private` calls.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`DeriveHttpError::Transport`] when the underlying HTTP client
160    /// cannot be constructed.
161    pub fn with_credentials(
162        base_url: impl Into<String>,
163        credentials: DeriveCredentials,
164        timeout_secs: Option<u64>,
165        proxy_url: Option<String>,
166        retry_config: Option<RetryConfig>,
167    ) -> Result<Self> {
168        let mut client = Self::new(base_url, timeout_secs, proxy_url, retry_config)?;
169        client.credentials = Some(credentials);
170        Ok(client)
171    }
172
173    /// Returns the configured base URL (no trailing slash).
174    #[must_use]
175    pub fn base_url(&self) -> &str {
176        &self.base_url
177    }
178
179    /// Returns `true` when credentials are installed.
180    #[must_use]
181    pub fn has_credentials(&self) -> bool {
182        self.credentials.is_some()
183    }
184
185    /// Allocates the next correlator id.
186    fn next_id(&self) -> u64 {
187        self.next_id.fetch_add(1, Ordering::Relaxed)
188    }
189
190    /// Sends an unauthenticated request and decodes the JSON-RPC envelope.
191    ///
192    /// Public endpoints are idempotent reads; this path retries transient
193    /// failures via the configured [`RetryManager`].
194    ///
195    /// # Errors
196    ///
197    /// Propagates transport, HTTP, and JSON-RPC errors. See [`DeriveHttpError`].
198    pub async fn send_public<P, R>(&self, method: &str, params: &P) -> Result<R>
199    where
200        P: Serialize + ?Sized,
201        R: DeserializeOwned,
202    {
203        let id = self.next_id();
204        self.dispatch(method, params, id, false, true, None).await
205    }
206
207    /// Sends an authenticated idempotent request (private reads).
208    ///
209    /// Used for `private/get_*` endpoints whose responses are pure reads of
210    /// venue state. Transient failures retry via the configured
211    /// [`RetryManager`].
212    ///
213    /// # Errors
214    ///
215    /// Returns [`DeriveHttpError::MissingCredentials`] when the client was
216    /// built without credentials. Other variants propagate from the transport
217    /// or the venue.
218    pub async fn send_private<P, R>(&self, method: &str, params: &P) -> Result<R>
219    where
220        P: Serialize + ?Sized,
221        R: DeserializeOwned,
222    {
223        if self.credentials.is_none() {
224            return Err(DeriveHttpError::MissingCredentials {
225                method: method.to_owned(),
226            });
227        }
228        let id = self.next_id();
229        self.dispatch(method, params, id, true, true, None).await
230    }
231
232    /// Sends an authenticated request exactly once (no retry).
233    ///
234    /// Used for state-changing endpoints (`private/order`, `private/cancel`,
235    /// `private/cancel_all`, `private/cancel_by_label`, `private/replace`)
236    /// where a transport-level failure leaves the venue's view of the
237    /// signed action ambiguous: the request may have been accepted before
238    /// the network broke. Automatic replay would either double-submit (when
239    /// the venue accepted) or trigger a duplicate-nonce rejection (which
240    /// the caller would surface as `OrderRejected` even though the original
241    /// is live). Callers are expected to resolve ambiguous outcomes via
242    /// reconciliation rather than retry here.
243    ///
244    /// Matching-engine writes must carry their instrument so the venue's
245    /// per-instrument allowance is paced too; use the typed wrappers
246    /// ([`Self::submit_order`], [`Self::cancel_order`],
247    /// [`Self::replace_order`]) which pass it through
248    /// `Self::send_private_write`.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`DeriveHttpError::MissingCredentials`] when the client was
253    /// built without credentials. Other variants propagate from the transport
254    /// or the venue.
255    pub async fn send_private_once<P, R>(&self, method: &str, params: &P) -> Result<R>
256    where
257        P: Serialize + ?Sized,
258        R: DeserializeOwned,
259    {
260        if self.credentials.is_none() {
261            return Err(DeriveHttpError::MissingCredentials {
262                method: method.to_owned(),
263            });
264        }
265        let id = self.next_id();
266        self.dispatch(method, params, id, true, false, None).await
267    }
268
269    /// Sends an authenticated matching-engine write exactly once, pacing it
270    /// against both the account-wide and the per-instrument allowances.
271    async fn send_private_write<P, R>(
272        &self,
273        method: &str,
274        params: &P,
275        instrument_name: Ustr,
276    ) -> Result<R>
277    where
278        P: Serialize + ?Sized,
279        R: DeserializeOwned,
280    {
281        if self.credentials.is_none() {
282            return Err(DeriveHttpError::MissingCredentials {
283                method: method.to_owned(),
284            });
285        }
286        let id = self.next_id();
287        self.dispatch(method, params, id, true, false, Some(instrument_name))
288            .await
289    }
290
291    /// Fetches the venue's listed instruments.
292    ///
293    /// `currency` is the perpetual/option underlying (e.g. `"ETH"`). When
294    /// `expired` is `true` the venue includes expired option strikes.
295    ///
296    /// # Errors
297    ///
298    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
299    pub async fn get_instruments(
300        &self,
301        currency: &str,
302        instrument_type: DeriveInstrumentType,
303        expired: bool,
304    ) -> Result<Vec<DeriveInstrument>> {
305        let params = serde_json::json!({
306            "currency": currency,
307            "instrument_type": instrument_type,
308            "expired": expired,
309        });
310        self.send_public("public/get_instruments", &params).await
311    }
312
313    /// Fetches a single instrument definition by name.
314    ///
315    /// Mirrors `public/get_instrument`, which the venue documents as the
316    /// per-asset variant of `public/get_instruments`. The returned record
317    /// matches one row of the bulk endpoint.
318    ///
319    /// # Errors
320    ///
321    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
322    pub async fn get_instrument(&self, instrument_name: &str) -> Result<DeriveInstrument> {
323        let params = serde_json::json!({
324            "instrument_name": instrument_name,
325        });
326        self.send_public("public/get_instrument", &params).await
327    }
328
329    /// Fetches a page of public trade history for the instrument.
330    ///
331    /// `from_timestamp` / `to_timestamp` are UNIX milliseconds and bound the
332    /// returned window. `page` is 1-indexed; `page_size` is capped by the venue
333    /// at 1000.
334    ///
335    /// # Errors
336    ///
337    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
338    pub async fn get_trade_history(
339        &self,
340        instrument_name: &str,
341        from_timestamp: Option<i64>,
342        to_timestamp: Option<i64>,
343        page: u32,
344        page_size: u32,
345    ) -> Result<DerivePublicTradesResult> {
346        let mut params = serde_json::Map::new();
347        params.insert("instrument_name".to_string(), instrument_name.into());
348        params.insert("page".to_string(), page.into());
349        params.insert("page_size".to_string(), page_size.into());
350        if let Some(from) = from_timestamp {
351            params.insert("from_timestamp".to_string(), from.into());
352        }
353
354        if let Some(to) = to_timestamp {
355            params.insert("to_timestamp".to_string(), to.into());
356        }
357
358        self.send_public("public/get_trade_history", &Value::Object(params))
359            .await
360    }
361
362    /// Fetches the public funding rate history for the instrument.
363    ///
364    /// `start_timestamp` / `end_timestamp` are UNIX milliseconds. `period`, if
365    /// provided, selects the sample interval in seconds.
366    ///
367    /// # Errors
368    ///
369    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
370    pub async fn get_funding_rate_history(
371        &self,
372        instrument_name: &str,
373        start_timestamp: Option<i64>,
374        end_timestamp: Option<i64>,
375        period: Option<u32>,
376    ) -> Result<DerivePublicFundingRateHistoryResult> {
377        let mut params = serde_json::Map::new();
378        params.insert("instrument_name".to_string(), instrument_name.into());
379        if let Some(start) = start_timestamp {
380            params.insert("start_timestamp".to_string(), start.into());
381        }
382
383        if let Some(end) = end_timestamp {
384            params.insert("end_timestamp".to_string(), end.into());
385        }
386
387        if let Some(period) = period {
388            params.insert("period".to_string(), period.into());
389        }
390
391        self.send_public("public/get_funding_rate_history", &Value::Object(params))
392            .await
393    }
394
395    /// Fetches OHLCV candles via `public/get_tradingview_chart_data`.
396    ///
397    /// `start_timestamp` / `end_timestamp` are UNIX **seconds** and bound the
398    /// returned window. `period` is the bucket size in seconds; the venue
399    /// accepts 60, 300, 900, 1800, 3600, 14400, 28800, 86400, and 604800.
400    /// The venue ships `result` as a flat array; the client decodes it
401    /// directly into `Vec<DerivePublicCandle>`.
402    ///
403    /// # Errors
404    ///
405    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
406    pub async fn get_candles(
407        &self,
408        instrument_name: &str,
409        start_timestamp: i64,
410        end_timestamp: i64,
411        period: u32,
412    ) -> Result<Vec<DerivePublicCandle>> {
413        let params = serde_json::json!({
414            "instrument_name": instrument_name,
415            "start_timestamp": start_timestamp,
416            "end_timestamp": end_timestamp,
417            "period": period,
418        });
419        self.send_public("public/get_tradingview_chart_data", &params)
420            .await
421    }
422
423    /// Fetches current ticker snapshots.
424    ///
425    /// `currency` is the underlying (`"ETH"`, `"BTC"`, etc.). Options require
426    /// both `currency` and `expiry_date`; perps and ERC-20 spot pairs reject
427    /// `expiry_date`.
428    ///
429    /// # Errors
430    ///
431    /// Propagates [`DeriveHttpError`] for transport, HTTP, and JSON-RPC failures.
432    pub async fn get_tickers(
433        &self,
434        instrument_type: DeriveInstrumentType,
435        currency: Option<&str>,
436        expiry_date: Option<&str>,
437    ) -> Result<DeriveTickersResult> {
438        let mut params = serde_json::Map::new();
439        params.insert(
440            "instrument_type".to_string(),
441            serde_json::to_value(instrument_type).map_err(DeriveHttpError::from)?,
442        );
443
444        if let Some(currency) = currency {
445            params.insert("currency".to_string(), currency.into());
446        }
447
448        if let Some(expiry_date) = expiry_date {
449            params.insert("expiry_date".to_string(), expiry_date.into());
450        }
451
452        self.send_public("public/get_tickers", &Value::Object(params))
453            .await
454    }
455
456    /// Fetches the current ticker snapshot for one instrument.
457    ///
458    /// This is a single-instrument convenience wrapper over
459    /// `public/get_tickers`, which replaced Derive's deprecated
460    /// `public/get_ticker` RPC.
461    ///
462    /// # Errors
463    ///
464    /// Propagates [`DeriveHttpError`] for transport, HTTP, JSON-RPC failures,
465    /// or when the response omits the requested instrument.
466    pub async fn get_ticker(&self, instrument_name: &str) -> Result<DeriveTickerSnapshot> {
467        let request = ticker_request(instrument_name)?;
468        let result = self
469            .get_tickers(
470                request.instrument_type,
471                Some(request.currency),
472                request.expiry_date,
473            )
474            .await?;
475        let mut ticker = result
476            .tickers
477            .get(instrument_name)
478            .cloned()
479            .ok_or_else(|| {
480                DeriveHttpError::decode(format!(
481                    "missing ticker `{instrument_name}` in public/get_tickers response"
482                ))
483            })?;
484        ticker.instrument_name = instrument_name.into();
485        Ok(ticker)
486    }
487
488    /// Submits a signed order to the venue.
489    ///
490    /// `params` must be the fully-built signed `private/order` body.
491    ///
492    /// # Errors
493    ///
494    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
495    /// were installed; otherwise propagates transport and venue errors.
496    pub async fn submit_order(&self, params: &DeriveOrderParams) -> Result<DeriveOrder> {
497        let result: DeriveOrderResult = self
498            .send_private_write("private/order", params, params.instrument_name)
499            .await?;
500        Ok(result.order)
501    }
502
503    /// Cancels a single order by venue order id.
504    ///
505    /// # Errors
506    ///
507    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
508    /// were installed; otherwise propagates transport and venue errors.
509    pub async fn cancel_order(&self, params: &DeriveCancelParams) -> Result<DeriveEmptyResult> {
510        self.send_private_write("private/cancel", params, params.instrument_name)
511            .await
512    }
513
514    /// Cancels every open order on the subaccount, optionally scoped to an
515    /// instrument.
516    ///
517    /// # Errors
518    ///
519    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
520    /// were installed; otherwise propagates transport and venue errors.
521    pub async fn cancel_all(&self, params: &DeriveCancelAllParams) -> Result<DeriveEmptyResult> {
522        self.send_private_once("private/cancel_all", params).await
523    }
524
525    /// Cancels every open order for the given user label on the subaccount.
526    ///
527    /// # Errors
528    ///
529    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
530    /// were installed; otherwise propagates transport and venue errors.
531    pub async fn cancel_by_label(
532        &self,
533        params: &DeriveCancelByLabelParams,
534    ) -> Result<DeriveCancelByLabelResult> {
535        self.send_private_once("private/cancel_by_label", params)
536            .await
537    }
538
539    /// Submits a signed `private/replace` request that cancels one order before
540    /// creating its replacement.
541    ///
542    /// `params` must be the fully-built typed request body.
543    ///
544    /// # Errors
545    ///
546    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
547    /// were installed; otherwise propagates transport and venue errors.
548    pub async fn replace_order(
549        &self,
550        params: &DeriveReplaceParams,
551    ) -> Result<DeriveReplaceOutcome> {
552        let result: DeriveReplaceResult = self
553            .send_private_write("private/replace", params, params.order.instrument_name)
554            .await?;
555        result
556            .into_outcome(&params.order_id_to_cancel, &params.order.label)
557            .map_err(DeriveHttpError::decode)
558    }
559
560    /// Returns the subaccount snapshot including margin, balances, and
561    /// open orders.
562    ///
563    /// # Errors
564    ///
565    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
566    /// were installed; otherwise propagates transport and venue errors.
567    pub async fn get_subaccount(
568        &self,
569        params: &DeriveGetSubaccountParams,
570    ) -> Result<DeriveSubaccount> {
571        self.send_private("private/get_subaccount", params).await
572    }
573
574    /// Returns currently open orders for the subaccount.
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_open_orders(
581        &self,
582        params: &DeriveGetOpenOrdersParams,
583    ) -> Result<DeriveOpenOrdersResult> {
584        self.send_private("private/get_open_orders", params).await
585    }
586
587    /// Returns currently untriggered trigger orders for the subaccount.
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_trigger_orders(
594        &self,
595        params: &DeriveGetTriggerOrdersParams,
596    ) -> Result<DeriveOpenOrdersResult> {
597        self.send_private("private/get_trigger_orders", params)
598            .await
599    }
600
601    /// Returns a single order by venue order id.
602    ///
603    /// # Errors
604    ///
605    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
606    /// were installed; otherwise propagates transport and venue errors.
607    pub async fn get_order(&self, params: &DeriveGetOrderParams) -> Result<DeriveOrder> {
608        self.send_private("private/get_order", params).await
609    }
610
611    /// Returns one page of order history for the subaccount, optionally
612    /// scoped to an instrument and time window.
613    ///
614    /// `from_timestamp` / `to_timestamp` are UNIX milliseconds. `page` is
615    /// 1-indexed and `page_size` is capped by the venue at 1000.
616    ///
617    /// # Errors
618    ///
619    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
620    /// were installed; otherwise propagates transport and venue errors.
621    pub async fn get_order_history(
622        &self,
623        params: &DeriveGetOrderHistoryParams,
624    ) -> Result<DeriveOrdersResult> {
625        self.send_private("private/get_order_history", params).await
626    }
627
628    /// Returns one page of subaccount trade history.
629    ///
630    /// # Errors
631    ///
632    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
633    /// were installed; otherwise propagates transport and venue errors.
634    pub async fn get_private_trade_history(
635        &self,
636        params: &DeriveGetTradeHistoryParams,
637    ) -> Result<DeriveTradesResult> {
638        self.send_private("private/get_trade_history", params).await
639    }
640
641    /// Returns the positions held by the subaccount.
642    ///
643    /// # Errors
644    ///
645    /// Returns [`DeriveHttpError::MissingCredentials`] when no credentials
646    /// were installed; otherwise propagates transport and venue errors.
647    pub async fn get_positions(
648        &self,
649        params: &DeriveGetPositionsParams,
650    ) -> Result<DerivePositionsResult> {
651        self.send_private("private/get_positions", params).await
652    }
653
654    async fn dispatch<P, R>(
655        &self,
656        method: &str,
657        params: &P,
658        id: u64,
659        authenticate: bool,
660        retry: bool,
661        instrument_name: Option<Ustr>,
662    ) -> Result<R>
663    where
664        P: Serialize + ?Sized,
665        R: DeserializeOwned,
666    {
667        let url = format!("{}/{}", self.base_url, method.trim_start_matches('/'));
668        let body_value = serde_json::to_value(params).map_err(DeriveHttpError::from)?;
669        let body = serde_json::to_vec(&body_value).map_err(DeriveHttpError::from)?;
670
671        let rate_class = rate_limit::rate_class_for_method(method);
672
673        // Sign per-attempt so the venue never sees a stale `X-LYRATIMESTAMP`
674        // after a long backoff window; single-shot writes still run the
675        // closure once and use freshly built headers. The fixed-window wait
676        // happens inside the closure, so pacing delays never consume the
677        // signed timestamp's validity.
678        let attempt = || async {
679            self.rate_limiter
680                .await_class_ready(rate_class, instrument_name.as_ref())
681                .await;
682
683            let mut headers: AHashMap<String, String> = AHashMap::with_capacity(4);
684            headers.insert("Content-Type".to_string(), "application/json".to_string());
685
686            if authenticate {
687                let auth = self.build_auth_headers(method)?;
688                headers.insert(HEADER_LYRA_WALLET.to_string(), auth.wallet);
689                headers.insert(HEADER_LYRA_TIMESTAMP.to_string(), auth.timestamp);
690                headers.insert(HEADER_LYRA_SIGNATURE.to_string(), auth.signature);
691            }
692
693            let response = self
694                .client
695                .post(
696                    url.clone(),
697                    None,
698                    Some(headers.into_iter().collect()),
699                    Some(body.clone()),
700                    Some(self.timeout_secs),
701                    None,
702                )
703                .await
704                .map_err(DeriveHttpError::from)?;
705
706            decode_envelope(method, id, response)
707        };
708
709        if retry {
710            self.retry_manager
711                .execute_with_retry(method, attempt, should_retry_http_error, |e| {
712                    DeriveHttpError::transport(e.to_string())
713                })
714                .await
715        } else {
716            attempt().await
717        }
718    }
719
720    fn build_auth_headers(&self, method: &str) -> Result<AuthHeaders> {
721        let credentials =
722            self.credentials
723                .as_ref()
724                .ok_or_else(|| DeriveHttpError::MissingCredentials {
725                    method: method.to_owned(),
726                })?;
727        let auth = build_rest_auth_headers(&credentials.wallet_address, &credentials.signer)?;
728        Ok(auth)
729    }
730}
731
732#[derive(Debug, Clone, Copy)]
733struct TickerRequest<'a> {
734    instrument_type: DeriveInstrumentType,
735    currency: &'a str,
736    expiry_date: Option<&'a str>,
737}
738
739fn ticker_request(instrument_name: &str) -> Result<TickerRequest<'_>> {
740    let Some((currency, suffix)) = instrument_name.split_once('-') else {
741        return Err(DeriveHttpError::decode(format!(
742            "invalid Derive instrument name `{instrument_name}`"
743        )));
744    };
745
746    if suffix == "PERP" {
747        return Ok(TickerRequest {
748            instrument_type: DeriveInstrumentType::Perp,
749            currency,
750            expiry_date: None,
751        });
752    }
753
754    let mut parts = suffix.split('-');
755    let Some(expiry_date) = parts.next() else {
756        return Ok(TickerRequest {
757            instrument_type: DeriveInstrumentType::Erc20,
758            currency,
759            expiry_date: None,
760        });
761    };
762    let has_option_tail = parts.clone().count() == 2;
763    if expiry_date.len() == 8 && expiry_date.chars().all(|c| c.is_ascii_digit()) && has_option_tail
764    {
765        return Ok(TickerRequest {
766            instrument_type: DeriveInstrumentType::Option,
767            currency,
768            expiry_date: Some(expiry_date),
769        });
770    }
771
772    Ok(TickerRequest {
773        instrument_type: DeriveInstrumentType::Erc20,
774        currency,
775        expiry_date: None,
776    })
777}
778
779fn build_client(
780    timeout_secs: u64,
781    proxy_url: Option<String>,
782) -> std::result::Result<(HttpClient, Arc<DeriveRateLimiter>), HttpClientError> {
783    // The REST limiter carries Trader-default matching allowances: execution
784    // writes travel over the WebSocket, whose client is built from the
785    // configured market-maker overrides.
786    let rate_limiter = Arc::new(FixedWindowLimiter::new(
787        rate_limit::FixedWindowLimits::rest(None, None),
788        MonotonicClock {},
789    ));
790    // Pacing runs caller-side in `dispatch` (before auth headers are built),
791    // so the network client carries no limiter of its own and never sleeps
792    // inside its request path.
793    let client = HttpClient::builder()
794        .timeout_secs(timeout_secs)
795        .maybe_proxy_url(proxy_url)
796        .rate_limiters(Vec::new())
797        .build()?;
798    Ok((client, rate_limiter))
799}
800
801fn trim_trailing_slash(url: String) -> String {
802    if url.ends_with('/') {
803        url.trim_end_matches('/').to_string()
804    } else {
805        url
806    }
807}
808
809fn decode_envelope<R: DeserializeOwned>(
810    method: &str,
811    request_id: u64,
812    response: HttpResponse,
813) -> Result<R> {
814    let status = response.status.as_u16();
815    let is_success_status = (200..300).contains(&status);
816    let body = response.body;
817
818    let envelope: JsonRpcResponse<R> = match serde_json::from_slice(&body) {
819        Ok(env) => env,
820        Err(e) => {
821            if !is_success_status {
822                let text = String::from_utf8_lossy(&body).into_owned();
823                return Err(DeriveHttpError::http(status, truncate(text, 512)));
824            }
825            return Err(DeriveHttpError::decode(format!(
826                "failed to decode `{method}` response: {e}",
827            )));
828        }
829    };
830
831    if let Some(err) = envelope.error {
832        return Err(DeriveHttpError::JsonRpc {
833            code: err.code,
834            message: err.message,
835            data: err.data,
836        });
837    }
838
839    // Gateways (Cloudflare, the wallet auth proxy) return non-2xx with a JSON body
840    // like {"message": "Unauthorized"} that parses into an empty envelope. Surface
841    // those as Http errors so retry/reconcile logic sees the real status code
842    // instead of MissingResult.
843    if !is_success_status {
844        let text = String::from_utf8_lossy(&body).into_owned();
845        return Err(DeriveHttpError::http(status, truncate(text, 512)));
846    }
847
848    if let Some(echoed) = envelope.id
849        && echoed != request_id
850    {
851        log::debug!(
852            "derive: id mismatch for `{method}` (sent={request_id}, recv={echoed}); accepting result",
853        );
854    }
855
856    envelope
857        .result
858        .ok_or_else(|| DeriveHttpError::MissingResult {
859            method: method.to_owned(),
860        })
861}
862
863fn truncate(s: String, max: usize) -> String {
864    if s.len() <= max {
865        return s;
866    }
867    let mut cutoff = max;
868    while cutoff > 0 && !s.is_char_boundary(cutoff) {
869        cutoff -= 1;
870    }
871    let mut out = String::with_capacity(cutoff + 3);
872    out.push_str(&s[..cutoff]);
873    out.push_str("...");
874    out
875}
876
877#[cfg(test)]
878mod tests {
879    use std::collections::HashMap;
880
881    use nautilus_network::http::{HttpStatus, StatusCode};
882    use rstest::rstest;
883
884    use super::*;
885
886    const SESSION_KEY_HEX: &str =
887        "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
888    const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
889
890    fn test_client() -> DeriveHttpClient {
891        DeriveHttpClient::new("https://api.example/", None, None, None).expect("client builds")
892    }
893
894    fn test_response(status: u16, body: &serde_json::Value) -> HttpResponse {
895        let status_code = StatusCode::from_u16(status).unwrap();
896        HttpResponse {
897            status: HttpStatus::new(status_code),
898            headers: HashMap::new(),
899            body: serde_json::to_vec(body).unwrap().into(),
900        }
901    }
902
903    #[rstest]
904    fn test_credentials_debug_redacts_signer() {
905        let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
906        let dbg = format!("{creds:?}");
907        assert!(dbg.contains("***redacted***"));
908        assert!(dbg.contains(TEST_WALLET));
909        assert!(!dbg.contains(SESSION_KEY_HEX));
910    }
911
912    #[rstest]
913    fn test_credentials_rejects_invalid_session_key() {
914        let err = DeriveCredentials::new(TEST_WALLET, "not-hex").expect_err("must reject");
915        match err {
916            DeriveHttpError::Decode(msg) => assert!(msg.contains("invalid session key")),
917            other => panic!("expected Decode, was {other:?}"),
918        }
919    }
920
921    #[rstest]
922    fn test_base_url_trims_trailing_slash() {
923        let client = test_client();
924        assert_eq!(client.base_url(), "https://api.example");
925    }
926
927    #[rstest]
928    fn test_new_has_no_credentials() {
929        assert!(!test_client().has_credentials());
930    }
931
932    #[rstest]
933    fn test_with_credentials_sets_creds() {
934        let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
935        let client =
936            DeriveHttpClient::with_credentials("https://api.example", creds, None, None, None)
937                .unwrap();
938        assert!(client.has_credentials());
939    }
940
941    #[rstest]
942    fn test_next_id_increments_monotonically() {
943        let client = test_client();
944        let a = client.next_id();
945        let b = client.next_id();
946        let c = client.next_id();
947        assert_eq!(b, a + 1);
948        assert_eq!(c, b + 1);
949    }
950
951    #[rstest]
952    fn test_decode_envelope_returns_result() {
953        let resp = test_response(200, &serde_json::json!({"id": 1, "result": {"ok": true}}));
954        let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
955        assert_eq!(value["ok"], true);
956    }
957
958    #[rstest]
959    fn test_decode_envelope_accepts_null_empty_result() {
960        let resp = test_response(200, &serde_json::json!({"id": 1, "result": null}));
961        let result: DeriveEmptyResult = decode_envelope("private/cancel", 1, resp).unwrap();
962        assert_eq!(result, DeriveEmptyResult {});
963    }
964
965    #[rstest]
966    fn test_decode_envelope_propagates_jsonrpc_error() {
967        let resp = test_response(
968            200,
969            &serde_json::json!({
970                "id": 1,
971                "error": {"code": -32601, "message": "Method not found"}
972            }),
973        );
974        let err: DeriveHttpError = decode_envelope::<Value>("public/missing", 1, resp).unwrap_err();
975        match err {
976            DeriveHttpError::JsonRpc { code, message, .. } => {
977                assert_eq!(code, -32601);
978                assert_eq!(message, "Method not found");
979            }
980            other => panic!("expected JsonRpc, was {other:?}"),
981        }
982    }
983
984    #[rstest]
985    fn test_decode_envelope_flags_missing_result() {
986        let resp = test_response(200, &serde_json::json!({"id": 1}));
987        let err = decode_envelope::<Value>("public/get_instruments", 1, resp).unwrap_err();
988        assert!(matches!(err, DeriveHttpError::MissingResult { .. }));
989    }
990
991    #[rstest]
992    fn test_decode_envelope_flags_non_2xx_with_unparsable_body() {
993        let status_code = StatusCode::from_u16(503).unwrap();
994        let response = HttpResponse {
995            status: HttpStatus::new(status_code),
996            headers: HashMap::new(),
997            body: bytes::Bytes::from_static(b"<html>upstream down</html>"),
998        };
999        let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1000        match err {
1001            DeriveHttpError::Http { status, message } => {
1002                assert_eq!(status, 503);
1003                assert!(message.contains("upstream down"));
1004            }
1005            other => panic!("expected Http, was {other:?}"),
1006        }
1007    }
1008
1009    #[rstest]
1010    fn test_decode_envelope_flags_non_2xx_with_non_envelope_json() {
1011        // Gateways return non-2xx with JSON bodies like {"message": "Unauthorized"}.
1012        // These parse as an empty JsonRpcResponse; the status must still surface.
1013        let resp = test_response(401, &serde_json::json!({"message": "Unauthorized"}));
1014        let err = decode_envelope::<Value>("private/order", 1, resp).unwrap_err();
1015        match err {
1016            DeriveHttpError::Http { status, message } => {
1017                assert_eq!(status, 401);
1018                assert!(message.contains("Unauthorized"));
1019            }
1020            other => panic!("expected Http, was {other:?}"),
1021        }
1022    }
1023
1024    #[rstest]
1025    fn test_decode_envelope_prefers_jsonrpc_error_over_http_status() {
1026        // When the venue returns a proper JSON-RPC error envelope with a non-2xx
1027        // status, the envelope wins because it carries richer venue context.
1028        let status_code = StatusCode::from_u16(400).unwrap();
1029        let body = serde_json::json!({
1030            "id": 1,
1031            "error": {"code": -32602, "message": "Invalid params"},
1032        });
1033        let response = HttpResponse {
1034            status: HttpStatus::new(status_code),
1035            headers: HashMap::new(),
1036            body: serde_json::to_vec(&body).unwrap().into(),
1037        };
1038        let err = decode_envelope::<Value>("private/order", 1, response).unwrap_err();
1039        assert!(matches!(err, DeriveHttpError::JsonRpc { code: -32602, .. }));
1040    }
1041
1042    #[rstest]
1043    fn test_truncate_handles_multi_byte_char_at_boundary() {
1044        // "Ω" is two bytes (0xCE 0xA9). Truncating to a length that lands mid-glyph
1045        // must not panic; we step back to the prior char boundary.
1046        let s = "ΩΩΩΩΩΩΩΩΩΩ".to_string();
1047        assert_eq!(s.len(), 20);
1048        let out = truncate(s, 5);
1049        assert!(out.ends_with("..."));
1050        let prefix = out.trim_end_matches("...");
1051        assert!(prefix.is_char_boundary(prefix.len()));
1052        assert!(prefix.chars().all(|c| c == 'Ω'));
1053    }
1054
1055    #[rstest]
1056    fn test_truncate_returns_input_when_under_limit() {
1057        let s = "short".to_string();
1058        assert_eq!(truncate(s, 16), "short");
1059    }
1060
1061    #[rstest]
1062    fn test_decode_envelope_non_2xx_body_with_non_ascii_does_not_panic() {
1063        // Regression: a Cloudflare-style 503 page containing non-ASCII bytes near
1064        // the truncation cutoff must not panic.
1065        let glyph = "Ω";
1066        let body = glyph.repeat(600);
1067        let status_code = StatusCode::from_u16(503).unwrap();
1068        let response = HttpResponse {
1069            status: HttpStatus::new(status_code),
1070            headers: HashMap::new(),
1071            body: body.into_bytes().into(),
1072        };
1073        let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1074        assert!(matches!(err, DeriveHttpError::Http { status: 503, .. }));
1075    }
1076
1077    #[rstest]
1078    fn test_decode_envelope_accepts_id_mismatch() {
1079        let resp = test_response(200, &serde_json::json!({"id": 99, "result": "ok"}));
1080        let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
1081        assert_eq!(value, serde_json::json!("ok"));
1082    }
1083
1084    #[tokio::test]
1085    async fn test_send_private_without_credentials_errors() {
1086        let client = test_client();
1087        let err = client
1088            .send_private::<_, Value>("private/order", &serde_json::json!({}))
1089            .await
1090            .expect_err("must require credentials");
1091
1092        match err {
1093            DeriveHttpError::MissingCredentials { method } => {
1094                assert_eq!(method, "private/order");
1095            }
1096            other => panic!("expected MissingCredentials, was {other:?}"),
1097        }
1098    }
1099}