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