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