Skip to main content

nautilus_derive/websocket/
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//! `tokio-tungstenite`-backed WebSocket client for the Derive JSON-RPC stream.
17//!
18//! [`DeriveWebSocketClient`] orchestrates the connection lifecycle and exposes
19//! a typed surface for `public/login` + the initial `ticker` channel. The
20//! actual I/O runs in `super::handler::FeedHandler`; the client communicates
21//! with it through an unbounded command channel and consumes
22//! [`DeriveWsMessage`] events.
23
24use std::{
25    fmt::Debug,
26    sync::{
27        Arc,
28        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
29    },
30    time::Duration,
31};
32
33use alloy::signers::local::PrivateKeySigner;
34use arc_swap::ArcSwap;
35use dashmap::DashMap;
36#[cfg(test)]
37use nautilus_common::live::get_runtime;
38use nautilus_core::UUID4;
39use nautilus_live::{
40    SocketControl,
41    task::{SharedTaskSlot, TaskJoinOutcome, TaskSlot, finish_task},
42};
43use nautilus_network::{
44    mode::ConnectionMode,
45    ratelimiter::clock::MonotonicClock,
46    websocket::{
47        AuthTracker, TransportBackend, WebSocketClient, WebSocketConfig, channel_message_handler,
48    },
49};
50use serde::{Serialize, de::DeserializeOwned};
51use serde_json::Value;
52use ustr::Ustr;
53
54use super::{
55    error::{DeriveWsError, Result},
56    handler::{
57        DeriveWsMessage, FeedHandler, HandlerCommand, orderbook_subscribe_params,
58        ticker_subscribe_params, trades_subscribe_params,
59    },
60    messages::{
61        DeriveWsChannel, WsLoginParams, WsLoginResult, WsSubscribeParams, WsSubscribeResult,
62        WsUnsubscribeParams, WsUnsubscribeResult, methods, orderbook_channel, ticker_channel,
63        trades_channel,
64    },
65};
66use crate::{
67    common::{
68        consts::{
69            RECONNECT_BACKOFF_FACTOR, RECONNECT_BASE_BACKOFF, RECONNECT_JITTER_MS,
70            RECONNECT_MAX_BACKOFF, RECONNECT_TIMEOUT, WS_HEARTBEAT_SECS, WS_HEARTBEAT_TIMEOUT,
71            WS_REQUEST_TIMEOUT,
72        },
73        enums::DeriveEnvironment,
74        rate_limit::{
75            DeriveRateLimiter, FixedWindowLimiter, FixedWindowLimits, RateClass,
76            rate_class_for_method,
77        },
78        urls,
79    },
80    http::{
81        models::{
82            DeriveCancelByInstrumentResult, DeriveCancelByLabelResult, DeriveEmptyResult,
83            DeriveOpenOrdersResult, DeriveOrder, DeriveOrderResult, DeriveReplaceOutcome,
84            DeriveReplaceResult,
85        },
86        query::{
87            DeriveCancelAllParams, DeriveCancelByInstrumentParams, DeriveCancelByLabelParams,
88            DeriveCancelParams, DeriveCancelTriggerOrderParams, DeriveGetTriggerOrdersParams,
89            DeriveOrderParams, DeriveReplaceParams, DeriveTriggerOrderParams,
90        },
91    },
92    signing::auth::build_ws_login,
93};
94
95/// Credentials for `public/login`. The session-key signer never escapes the
96/// client; only the wallet address is exposed via [`Debug`].
97#[derive(Clone)]
98pub struct DeriveWsCredentials {
99    /// Derive Chain smart-contract wallet address (`0x`-prefixed, 42 chars).
100    pub wallet_address: String,
101    /// secp256k1 session-key signer.
102    pub signer: PrivateKeySigner,
103}
104
105impl DeriveWsCredentials {
106    /// Constructs credentials by parsing `session_key_hex` into a signer.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`DeriveWsError::Transport`] when the session-key hex cannot be parsed.
111    pub fn new(wallet_address: impl Into<String>, session_key_hex: &str) -> Result<Self> {
112        let signer: PrivateKeySigner = session_key_hex
113            .parse()
114            .map_err(|e| DeriveWsError::transport(format!("invalid session key: {e}")))?;
115        Ok(Self {
116            wallet_address: wallet_address.into(),
117            signer,
118        })
119    }
120}
121
122impl Debug for DeriveWsCredentials {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct(stringify!(DeriveWsCredentials))
125            .field("wallet_address", &self.wallet_address)
126            .field("signer", &"***redacted***")
127            .finish()
128    }
129}
130
131// Fixed-window rate limiter shared with the command handles so each frame is
132// paced in the caller's task before it is enqueued for the feed handler.
133type WsRateLimiter = DeriveRateLimiter;
134
135const MAX_SESSION_RECOVERY_ATTEMPTS: u32 = 3;
136const SUBSCRIPTION_ACCEPTED_STATUSES: &[&str] = &["ok"];
137const SUBSCRIPTION_REPLAY_ACCEPTED_STATUSES: &[&str] = &["ok", "already subscribed"];
138pub(super) const UNAUTHENTICATED_CONNECTION_EPOCH: u64 = u64::MAX;
139
140/// WebSocket client for the Derive JSON-RPC stream.
141///
142/// Construct with [`Self::new`] (public-only) or [`Self::with_credentials`]
143/// when private channels and signed actions are needed. Call [`Self::connect`]
144/// before any subscribe call; [`Self::disconnect`] tears the connection down.
145#[derive(Debug)]
146pub struct DeriveWebSocketClient {
147    url: String,
148    transport_backend: TransportBackend,
149    proxy_url: Option<String>,
150    connection_mode: Arc<ArcSwap<AtomicU8>>,
151    connection_epoch: Arc<ArcSwap<AtomicU64>>,
152    signal: Arc<AtomicBool>,
153    auth_tracker: AuthTracker,
154    authenticated_epoch: Arc<AtomicU64>,
155    credentials: Option<DeriveWsCredentials>,
156    next_id: Arc<AtomicU64>,
157    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
158    out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<DeriveWsMessage>>,
159    subscriptions: Arc<DashMap<String, ()>>,
160    subscription_lock: Arc<tokio::sync::Mutex<()>>,
161    task_handle: TaskSlot<()>,
162    send_task: Arc<SharedTaskSlot<()>>,
163    shutdown_errors: Vec<String>,
164    request_timeout: Duration,
165    conn_id: Arc<ArcSwap<String>>,
166    rate_limiter: Arc<WsRateLimiter>,
167    socket_control: Option<SocketControl>,
168}
169
170#[derive(Clone, Debug)]
171pub(crate) struct DeriveWebSocketShutdownHandle {
172    signal: Arc<AtomicBool>,
173}
174
175impl DeriveWebSocketShutdownHandle {
176    pub(crate) fn begin_shutdown(&self) {
177        self.signal.store(true, Ordering::Release);
178    }
179}
180
181struct DeriveWebSocketSetupGuard {
182    shutdown: DeriveWebSocketShutdownHandle,
183    armed: bool,
184}
185
186impl DeriveWebSocketSetupGuard {
187    fn new(shutdown: DeriveWebSocketShutdownHandle) -> Self {
188        Self {
189            shutdown,
190            armed: true,
191        }
192    }
193
194    fn disarm(mut self) {
195        self.armed = false;
196    }
197}
198
199impl Drop for DeriveWebSocketSetupGuard {
200    fn drop(&mut self) {
201        if self.armed {
202            self.shutdown.begin_shutdown();
203        }
204    }
205}
206
207/// Cloneable command handle for Derive public market data subscriptions.
208#[derive(Debug, Clone)]
209pub struct DeriveWebSocketSubscriptionHandle {
210    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
211    subscriptions: Arc<DashMap<String, ()>>,
212    subscription_lock: Arc<tokio::sync::Mutex<()>>,
213    request_timeout: Duration,
214    rate_limiter: Arc<WsRateLimiter>,
215}
216
217/// Cloneable handle for issuing signed `private/*` trading requests over the
218/// WebSocket transport.
219///
220/// Carries the same `cmd_tx` the owning [`DeriveWebSocketClient`] swaps on
221/// connect/reconnect, so a handle obtained at construction stays valid for the
222/// client's lifetime. The handle is transport-only: it sends the pre-signed
223/// body and surfaces the venue's JSON-RPC outcome. Session authorization is the
224/// client's responsibility (via `public/login`).
225#[derive(Debug, Clone)]
226pub struct DeriveWsExecutionHandle {
227    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
228    auth_tracker: AuthTracker,
229    request_timeout: Duration,
230    conn_id: Arc<ArcSwap<String>>,
231    rate_limiter: Arc<WsRateLimiter>,
232}
233
234#[derive(Debug)]
235pub(crate) struct MatchingRateLimitReservation {
236    method: &'static str,
237    instrument_name: Ustr,
238    window: u32,
239}
240
241impl DeriveWebSocketClient {
242    /// Builds a public-only client. URL falls back to the environment default
243    /// when `url` is `None`.
244    #[must_use]
245    pub fn new(
246        url: Option<String>,
247        environment: DeriveEnvironment,
248        transport_backend: TransportBackend,
249        proxy_url: Option<String>,
250    ) -> Self {
251        let url = url.unwrap_or_else(|| urls::ws_url(environment).to_string());
252        Self::build(
253            url,
254            transport_backend,
255            proxy_url,
256            None,
257            FixedWindowLimits::websocket(None, None),
258        )
259    }
260
261    /// Builds a client that will issue `public/login` on connect and replay
262    /// it after each reconnect.
263    ///
264    /// `max_matching_requests_per_second` sets the account-wide matching
265    /// allowance for order writes and `max_per_instrument_matching_requests_per_second`
266    /// the independent per-instrument allowance; `None` applies the Trader-tier
267    /// default of each. See [`crate::common::rate_limit`].
268    #[must_use]
269    pub fn with_credentials(
270        url: Option<String>,
271        environment: DeriveEnvironment,
272        transport_backend: TransportBackend,
273        proxy_url: Option<String>,
274        credentials: DeriveWsCredentials,
275        max_matching_requests_per_second: Option<u32>,
276        max_per_instrument_matching_requests_per_second: Option<u32>,
277    ) -> Self {
278        let url = url.unwrap_or_else(|| urls::ws_url(environment).to_string());
279        let limits = FixedWindowLimits::websocket(
280            max_matching_requests_per_second,
281            max_per_instrument_matching_requests_per_second,
282        );
283        Self::build(url, transport_backend, proxy_url, Some(credentials), limits)
284    }
285
286    fn build(
287        url: String,
288        transport_backend: TransportBackend,
289        proxy_url: Option<String>,
290        credentials: Option<DeriveWsCredentials>,
291        limits: FixedWindowLimits,
292    ) -> Self {
293        let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
294            ConnectionMode::Closed as u8,
295        ))));
296        let connection_epoch = Arc::new(ArcSwap::new(Arc::new(AtomicU64::new(0))));
297
298        // Placeholder channel; replaced by connect() before commands are issued.
299        let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
300
301        // Matching writes draw on the account-wide and per-instrument
302        // allowances; custom cancellation methods have their own windows and
303        // login, subscription, and reads use the non-matching allowance.
304        // Handles pace each frame in the caller's task before enqueueing, so
305        // the feed handler never sleeps.
306        let rate_limiter = Arc::new(FixedWindowLimiter::new(limits, MonotonicClock {}));
307        Self {
308            url,
309            transport_backend,
310            proxy_url,
311            connection_mode,
312            connection_epoch,
313            signal: Arc::new(AtomicBool::new(false)),
314            auth_tracker: AuthTracker::new(),
315            authenticated_epoch: Arc::new(AtomicU64::new(UNAUTHENTICATED_CONNECTION_EPOCH)),
316            credentials,
317            next_id: Arc::new(AtomicU64::new(1)),
318            cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
319            out_rx: None,
320            subscriptions: Arc::new(DashMap::new()),
321            subscription_lock: Arc::new(tokio::sync::Mutex::new(())),
322            task_handle: TaskSlot::new(),
323            send_task: Arc::new(SharedTaskSlot::new()),
324            shutdown_errors: Vec::new(),
325            request_timeout: WS_REQUEST_TIMEOUT,
326            conn_id: Arc::new(ArcSwap::from_pointee(UUID4::new().to_string())),
327            rate_limiter,
328            socket_control: None,
329        }
330    }
331
332    /// Configures socket state reporting and reconnect control.
333    #[must_use]
334    pub fn with_socket_control(mut self, control: SocketControl) -> Self {
335        self.socket_control = Some(control);
336        self
337    }
338
339    /// Returns the configured WebSocket URL.
340    #[must_use]
341    pub fn url(&self) -> &str {
342        &self.url
343    }
344
345    /// Sets the per-operation WebSocket timeout (login, subscribe, reads, writes).
346    ///
347    /// Must be called before `connect()`. Defaults to `WS_REQUEST_TIMEOUT`.
348    pub fn set_request_timeout(&mut self, timeout: Duration) {
349        self.request_timeout = timeout;
350    }
351
352    /// Returns `true` when credentials are configured and the venue has
353    /// confirmed the latest `public/login`. Cleared on reconnect.
354    #[must_use]
355    pub fn is_authenticated(&self) -> bool {
356        self.auth_tracker.is_authenticated()
357            && self.is_active()
358            && self.authenticated_epoch.load(Ordering::Acquire)
359                == self.connection_epoch.load().load(Ordering::Acquire)
360    }
361
362    /// Returns `true` while the underlying transport is in the active state.
363    #[must_use]
364    pub fn is_active(&self) -> bool {
365        self.connection_mode.load().load(Ordering::Relaxed) == ConnectionMode::Active as u8
366    }
367
368    /// Establishes the WebSocket connection and spawns the I/O handler task.
369    ///
370    /// When credentials are configured, issues `public/login` and awaits the
371    /// venue's acknowledgement before returning.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`DeriveWsError::Transport`] for handshake failures and
376    /// propagates [`DeriveWsError::Auth`] / [`DeriveWsError::JsonRpc`] when
377    /// the login flow fails.
378    pub async fn connect(&mut self) -> Result<()> {
379        // Fast path requires authenticated session when creds are configured;
380        // otherwise fall through and rebuild so `Ok` always implies authenticated.
381        let auth_ok = self.credentials.is_none() || self.is_authenticated();
382        if self.is_active() && auth_ok && self.task_handle.is_some() {
383            log::warn!("Derive WebSocket already connected");
384            return Ok(());
385        }
386
387        // Tear down stale state so we don't orphan the old handler task on rebuild.
388        if self.task_handle.is_some() || !self.send_task.is_empty() {
389            log::debug!("Tearing down stale Derive WebSocket state before connect");
390            self.teardown().await?;
391        }
392
393        self.signal.store(false, Ordering::Release);
394        let setup_guard = DeriveWebSocketSetupGuard::new(self.shutdown_handle());
395
396        self.authenticated_epoch
397            .store(UNAUTHENTICATED_CONNECTION_EPOCH, Ordering::Release);
398
399        let (message_handler, raw_rx) = channel_message_handler();
400        let cfg = WebSocketConfig {
401            url: self.url.clone(),
402            headers: vec![],
403            heartbeat_interval_secs: Some(WS_HEARTBEAT_SECS),
404            heartbeat_payload: None,
405            connect_timeout_ms: Some(RECONNECT_TIMEOUT.as_millis() as u64),
406            reconnect_delay_initial_ms: Some(RECONNECT_BASE_BACKOFF.as_millis() as u64),
407            reconnect_delay_max_ms: Some(RECONNECT_MAX_BACKOFF.as_millis() as u64),
408            reconnect_backoff_factor: Some(RECONNECT_BACKOFF_FACTOR),
409            reconnect_jitter_ms: Some(RECONNECT_JITTER_MS),
410            reconnect_max_attempts: None,
411            heartbeat_timeout_secs: Some(WS_HEARTBEAT_TIMEOUT.as_secs()),
412            idle_timeout_ms: None,
413            backend: self.transport_backend,
414            proxy_url: self.proxy_url.clone(),
415        };
416        // Rate limiting runs caller-side via `self.rate_limiter` before frames
417        // are enqueued, so the network client's own limiter is left unconfigured
418        // and never sleeps inside the single feed-handler task.
419        let client = WebSocketClient::builder()
420            .config(cfg)
421            .message_handler(message_handler)
422            .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
423            .connect()
424            .await
425            .map_err(|e| DeriveWsError::transport(e.to_string()))?;
426
427        // Register the tracker so the network controller clears
428        // `is_authenticated()` on dead-socket detection, not just on the
429        // later RECONNECTED sentinel.
430        client.set_auth_tracker(self.auth_tracker.clone(), false);
431
432        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
433        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<DeriveWsMessage>();
434
435        *self.cmd_tx.write().await = cmd_tx.clone();
436        self.out_rx = Some(out_rx);
437        self.conn_id.store(Arc::new(UUID4::new().to_string()));
438
439        let connection_mode = client.connection_mode_atomic();
440        let connection_epoch = client.connection_epoch_atomic();
441        let reconnect_handle = client.reconnect_handle();
442        self.connection_mode.store(Arc::clone(&connection_mode));
443        self.connection_epoch.store(Arc::clone(&connection_epoch));
444        log::debug!("Derive WebSocket connected: {}", self.url);
445
446        if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
447            return Err(DeriveWsError::transport(format!(
448                "failed to send SetClient command: {e}",
449            )));
450        }
451
452        let signal = Arc::clone(&self.signal);
453        let auth_tracker = self.auth_tracker.clone();
454        let authenticated_epoch = Arc::clone(&self.authenticated_epoch);
455        let next_id = Arc::clone(&self.next_id);
456        let credentials = self.credentials.clone();
457        let subscriptions = Arc::clone(&self.subscriptions);
458        let subscription_lock = Arc::clone(&self.subscription_lock);
459        let conn_id = Arc::clone(&self.conn_id);
460        let cmd_tx_for_loop = cmd_tx.clone();
461        let rate_limiter = Arc::clone(&self.rate_limiter);
462        let request_timeout = self.request_timeout;
463        let recovery_connection_mode = Arc::clone(&connection_mode);
464        let recovery_connection_epoch = Arc::clone(&connection_epoch);
465        let send_task = Arc::clone(&self.send_task);
466
467        if let Err(e) = self.task_handle.spawn(async move {
468            let (recovery_tx, mut recovery_rx) = tokio::sync::mpsc::unbounded_channel::<u64>();
469            let recovery_out_tx = out_tx.clone();
470            let recovery_cmd_tx = cmd_tx_for_loop.clone();
471            let recovery_auth_tracker = auth_tracker.clone();
472            let recovery_authenticated_epoch = Arc::clone(&authenticated_epoch);
473            let recovery_subscriptions = Arc::clone(&subscriptions);
474            let recovery_subscription_lock = Arc::clone(&subscription_lock);
475            let recovery_rate_limiter = Arc::clone(&rate_limiter);
476            let recovery_credentials = credentials.clone();
477            let recovery_mode = Arc::clone(&recovery_connection_mode);
478            let recovery_epoch = Arc::clone(&recovery_connection_epoch);
479            let mut recovery_tasks = tokio::task::JoinSet::new();
480
481            recovery_tasks.spawn(async move {
482                while let Some(mut requested_epoch) = recovery_rx.recv().await {
483                    while let Ok(epoch) = recovery_rx.try_recv() {
484                        requested_epoch = requested_epoch.max(epoch);
485                    }
486
487                    loop {
488                        match recover_session(
489                            &recovery_rate_limiter,
490                            &recovery_cmd_tx,
491                            &recovery_auth_tracker,
492                            &recovery_authenticated_epoch,
493                            &recovery_mode,
494                            &recovery_epoch,
495                            recovery_credentials.as_ref(),
496                            &recovery_subscriptions,
497                            &recovery_subscription_lock,
498                            request_timeout,
499                        )
500                        .await
501                        {
502                            Ok(recovered_epoch) => {
503                                while let Ok(epoch) = recovery_rx.try_recv() {
504                                    requested_epoch = requested_epoch.max(epoch);
505                                }
506
507                                if requested_epoch > recovered_epoch
508                                    || !session_connection_is_active(
509                                        &recovery_mode,
510                                        &recovery_epoch,
511                                        recovered_epoch,
512                                    )
513                                {
514                                    continue;
515                                }
516
517                                if recovery_out_tx.send(DeriveWsMessage::Reconnected).is_err() {
518                                    log::debug!("Derive outer receiver dropped during recovery");
519                                }
520                                break;
521                            }
522                            Err(e) => {
523                                let mut retry = false;
524
525                                while let Ok(epoch) = recovery_rx.try_recv() {
526                                    requested_epoch = requested_epoch.max(epoch);
527                                    retry = true;
528                                }
529
530                                if retry {
531                                    continue;
532                                }
533                                log::error!("Derive WebSocket session recovery failed: {e}");
534                                let _ = recovery_out_tx
535                                    .send(DeriveWsMessage::SessionRecoveryFailed(e.to_string()));
536                                let _ = recovery_cmd_tx.send(HandlerCommand::Disconnect);
537                                return;
538                            }
539                        }
540                    }
541                }
542            });
543
544            let mut handler = FeedHandler::new_with_send_task(
545                signal,
546                cmd_rx,
547                raw_rx,
548                next_id,
549                auth_tracker.clone(),
550                Arc::clone(&authenticated_epoch),
551                send_task,
552            );
553
554            loop {
555                match handler.next().await {
556                    Some(DeriveWsMessage::Reconnected) => {
557                        log::info!("Derive WebSocket re-establishing session after reconnect");
558                        conn_id.store(Arc::new(UUID4::new().to_string()));
559                        let epoch = recovery_connection_epoch.load(Ordering::Acquire);
560                        if recovery_tx.send(epoch).is_err() {
561                            log::error!("Derive WebSocket recovery task stopped unexpectedly");
562                            let _ = cmd_tx_for_loop.send(HandlerCommand::Disconnect);
563                        }
564                    }
565                    Some(msg) => {
566                        if out_tx.send(msg).is_err() {
567                            log::debug!("Derive outer receiver dropped, exiting stream loop");
568                            break;
569                        }
570                    }
571                    None => {
572                        log::debug!("Derive handler task ended");
573                        break;
574                    }
575                }
576            }
577        }) {
578            let shutdown_result = self.teardown().await;
579            return Err(DeriveWsError::transport(match shutdown_result {
580                Ok(()) => format!("failed to start WebSocket handler task: {e}"),
581                Err(shutdown_error) => format!(
582                    "failed to start WebSocket handler task: {e}; startup rollback failed: \
583                     {shutdown_error}"
584                ),
585            }));
586        }
587
588        if let Some(control) = &self.socket_control {
589            control.register(move || reconnect_handle.request_reconnect());
590        }
591
592        if let Some(creds) = self.credentials.clone()
593            && let Err(e) = login_via_handler(
594                &self.rate_limiter,
595                &cmd_tx,
596                &self.auth_tracker,
597                &self.authenticated_epoch,
598                &connection_mode,
599                &connection_epoch,
600                &creds,
601                self.request_timeout,
602            )
603            .await
604        {
605            // Without teardown, a retry connect() would short-circuit on
606            // is_active() and return Ok without a valid session.
607            log::warn!("Derive WebSocket login failed; tearing down transport: {e}");
608            self.teardown().await?;
609            return Err(e);
610        }
611
612        setup_guard.disarm();
613        Ok(())
614    }
615
616    pub(crate) fn begin_shutdown(&self) {
617        self.signal.store(true, Ordering::Release);
618    }
619
620    pub(crate) fn shutdown_handle(&self) -> DeriveWebSocketShutdownHandle {
621        DeriveWebSocketShutdownHandle {
622            signal: Arc::clone(&self.signal),
623        }
624    }
625
626    /// Signals the handler to disconnect, aborts the spawn task, and resets
627    /// the client's transport-related state. Shared by [`Self::disconnect`]
628    /// and the login-failure branch of [`Self::connect`].
629    async fn teardown(&mut self) -> Result<()> {
630        self.begin_shutdown();
631
632        if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
633            log::debug!(
634                "Failed to enqueue Disconnect command (handler may already be shut down): {e}",
635            );
636        }
637
638        match finish_task(
639            &mut self.task_handle,
640            Duration::from_secs(2),
641            Duration::from_secs(2),
642        )
643        .await
644        {
645            None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => {}
646            Some(TaskJoinOutcome::Failed(error)) => self
647                .shutdown_errors
648                .push(format!("WebSocket handler task failed: {error}")),
649            Some(TaskJoinOutcome::Incomplete) => self
650                .shutdown_errors
651                .push("WebSocket handler task did not stop after abort".to_string()),
652        }
653
654        if self.task_handle.is_some() {
655            if let Some(control) = &self.socket_control {
656                control.deregister();
657            }
658            return self.take_shutdown_result();
659        }
660
661        match self
662            .send_task
663            .finish(Duration::from_secs(1), Duration::from_secs(2))
664            .await
665        {
666            None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => {}
667            Some(TaskJoinOutcome::Failed(error)) => self
668                .shutdown_errors
669                .push(format!("WebSocket send worker failed: {error}")),
670            Some(TaskJoinOutcome::Incomplete) => self
671                .shutdown_errors
672                .push("WebSocket send worker did not stop after abort".to_string()),
673        }
674
675        if !self.send_task.is_empty() {
676            if let Some(control) = &self.socket_control {
677                control.deregister();
678            }
679            return self.take_shutdown_result();
680        }
681
682        // Subscriptions are also dropped: the venue session ended with the
683        // transport, so a fresh connect() must re-issue them.
684        let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
685        *self.cmd_tx.write().await = placeholder_tx;
686        self.out_rx = None;
687        self.connection_mode
688            .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
689        self.connection_epoch.store(Arc::new(AtomicU64::new(0)));
690        self.auth_tracker.invalidate();
691        self.authenticated_epoch
692            .store(UNAUTHENTICATED_CONNECTION_EPOCH, Ordering::Release);
693        self.subscriptions.clear();
694        self.signal.store(false, Ordering::Relaxed);
695
696        if let Some(control) = &self.socket_control {
697            control.deregister();
698        }
699
700        self.take_shutdown_result()
701    }
702
703    fn take_shutdown_result(&mut self) -> Result<()> {
704        if self.shutdown_errors.is_empty() {
705            Ok(())
706        } else {
707            Err(DeriveWsError::transport(
708                std::mem::take(&mut self.shutdown_errors).join("; "),
709            ))
710        }
711    }
712
713    /// Disconnects the WebSocket connection and awaits the handler task.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`DeriveWsError::Transport`] when the disconnect command
718    /// cannot be enqueued; the handler still tears down on signal.
719    pub async fn disconnect(&mut self) -> Result<()> {
720        log::debug!("Disconnecting Derive WebSocket");
721        self.begin_shutdown();
722        self.teardown().await
723    }
724
725    /// Subscribes to `ticker_slim.{instrument_name}.{interval}`. `interval` is the
726    /// millisecond cadence string the venue exposes (e.g. `"100"`, `"1000"`).
727    ///
728    /// # Errors
729    ///
730    /// Propagates JSON-RPC errors raised by the venue and transport-level
731    /// failures.
732    pub async fn subscribe_ticker(&self, instrument_name: &str, interval: &str) -> Result<()> {
733        self.subscription_handle()
734            .subscribe_ticker(instrument_name, interval)
735            .await
736    }
737
738    /// Unsubscribes from `ticker_slim.{instrument_name}.{interval}`.
739    ///
740    /// # Errors
741    ///
742    /// Propagates JSON-RPC errors raised by the venue and transport-level
743    /// failures.
744    pub async fn unsubscribe_ticker(&self, instrument_name: &str, interval: &str) -> Result<()> {
745        self.subscription_handle()
746            .unsubscribe_ticker(instrument_name, interval)
747            .await
748    }
749
750    /// Subscribes to `orderbook.{instrument_name}.{group}.{depth}`.
751    ///
752    /// # Errors
753    ///
754    /// Propagates JSON-RPC errors raised by the venue and transport-level
755    /// failures.
756    pub async fn subscribe_orderbook(
757        &self,
758        instrument_name: &str,
759        group: &str,
760        depth: &str,
761    ) -> Result<()> {
762        self.subscription_handle()
763            .subscribe_orderbook(instrument_name, group, depth)
764            .await
765    }
766
767    /// Unsubscribes from `orderbook.{instrument_name}.{group}.{depth}`.
768    ///
769    /// # Errors
770    ///
771    /// Propagates JSON-RPC errors raised by the venue and transport-level
772    /// failures.
773    pub async fn unsubscribe_orderbook(
774        &self,
775        instrument_name: &str,
776        group: &str,
777        depth: &str,
778    ) -> Result<()> {
779        self.subscription_handle()
780            .unsubscribe_orderbook(instrument_name, group, depth)
781            .await
782    }
783
784    /// Subscribes to `trades.{instrument_type}.{currency}`.
785    ///
786    /// # Errors
787    ///
788    /// Propagates JSON-RPC errors raised by the venue and transport-level
789    /// failures.
790    pub async fn subscribe_trades(&self, instrument_type: &str, currency: &str) -> Result<()> {
791        self.subscription_handle()
792            .subscribe_trades(instrument_type, currency)
793            .await
794    }
795
796    /// Unsubscribes from `trades.{instrument_type}.{currency}`.
797    ///
798    /// # Errors
799    ///
800    /// Propagates JSON-RPC errors raised by the venue and transport-level
801    /// failures.
802    pub async fn unsubscribe_trades(&self, instrument_type: &str, currency: &str) -> Result<()> {
803        self.subscription_handle()
804            .unsubscribe_trades(instrument_type, currency)
805            .await
806    }
807
808    /// Subscribes to a list of channel topics in a single `subscribe` frame.
809    ///
810    /// Used by the execution client to bulk-subscribe to the private
811    /// `{subaccount_id}.orders`, `{subaccount_id}.trades`, and
812    /// `{subaccount_id}.balances` channels after login.
813    ///
814    /// # Errors
815    ///
816    /// Propagates JSON-RPC errors raised by the venue and transport-level
817    /// failures.
818    pub async fn subscribe_channels<C>(&self, channels: Vec<C>) -> Result<()>
819    where
820        C: Into<DeriveWsChannel>,
821    {
822        self.subscription_handle()
823            .subscribe_channels(channels)
824            .await
825    }
826
827    /// Unsubscribes from a list of channel topics in a single
828    /// `unsubscribe` frame.
829    ///
830    /// # Errors
831    ///
832    /// Propagates JSON-RPC errors raised by the venue and transport-level
833    /// failures.
834    pub async fn unsubscribe_channels<C>(&self, channels: Vec<C>) -> Result<()>
835    where
836        C: Into<DeriveWsChannel>,
837    {
838        self.subscription_handle()
839            .unsubscribe_channels(channels)
840            .await
841    }
842
843    /// Returns the next event emitted by the handler.
844    pub async fn next_event(&mut self) -> Option<DeriveWsMessage> {
845        if let Some(rx) = self.out_rx.as_mut() {
846            rx.recv().await
847        } else {
848            None
849        }
850    }
851
852    /// Returns the count of channels the client currently has confirmed
853    /// subscriptions for.
854    #[must_use]
855    pub fn subscription_count(&self) -> usize {
856        self.subscriptions.len()
857    }
858
859    /// Returns a cloneable handle for issuing subscription commands.
860    #[must_use]
861    pub fn subscription_handle(&self) -> DeriveWebSocketSubscriptionHandle {
862        DeriveWebSocketSubscriptionHandle {
863            cmd_tx: Arc::clone(&self.cmd_tx),
864            subscriptions: Arc::clone(&self.subscriptions),
865            subscription_lock: Arc::clone(&self.subscription_lock),
866            request_timeout: self.request_timeout,
867            rate_limiter: Arc::clone(&self.rate_limiter),
868        }
869    }
870
871    /// Returns a cloneable handle for issuing signed `private/*` trading
872    /// requests.
873    ///
874    /// The handle shares the client's command channel, so it stays valid across
875    /// reconnects (the channel is swapped behind a shared lock). Obtain it once
876    /// and clone it into each order-submission task.
877    #[must_use]
878    pub fn execution_handle(&self) -> DeriveWsExecutionHandle {
879        DeriveWsExecutionHandle {
880            cmd_tx: Arc::clone(&self.cmd_tx),
881            auth_tracker: self.auth_tracker.clone(),
882            request_timeout: self.request_timeout,
883            conn_id: Arc::clone(&self.conn_id),
884            rate_limiter: Arc::clone(&self.rate_limiter),
885        }
886    }
887
888    /// Takes the event receiver from the client.
889    ///
890    /// This lets the live data client own the receive loop while subscription
891    /// commands continue through [`Self::subscription_handle`].
892    pub fn take_event_receiver(
893        &mut self,
894    ) -> Option<tokio::sync::mpsc::UnboundedReceiver<DeriveWsMessage>> {
895        self.out_rx.take()
896    }
897}
898
899impl Drop for DeriveWebSocketClient {
900    fn drop(&mut self) {
901        self.signal.store(true, Ordering::Relaxed);
902
903        if let Some(handle) = self.task_handle.as_ref() {
904            handle.abort();
905        }
906
907        if let Some(control) = &self.socket_control {
908            control.deregister();
909        }
910    }
911}
912
913impl DeriveWebSocketSubscriptionHandle {
914    pub(crate) fn has_subscription(&self, channel: &str) -> bool {
915        self.subscriptions.contains_key(channel)
916    }
917
918    pub(crate) fn forget_subscription(&self, channel: &str) {
919        self.subscriptions.remove(channel);
920    }
921
922    pub(crate) fn remember_subscription(&self, channel: &str) {
923        self.subscriptions.insert(channel.to_string(), ());
924    }
925
926    /// Subscribes to `ticker_slim.{instrument_name}.{interval}`.
927    ///
928    /// # Errors
929    ///
930    /// Propagates JSON-RPC errors raised by the venue and transport-level
931    /// failures.
932    pub async fn subscribe_ticker(&self, instrument_name: &str, interval: &str) -> Result<()> {
933        let channel = ticker_channel(instrument_name, interval);
934        let params = ticker_subscribe_params(instrument_name, interval);
935        self.send_subscribe(channel, &params).await
936    }
937
938    /// Unsubscribes from `ticker_slim.{instrument_name}.{interval}`.
939    ///
940    /// # Errors
941    ///
942    /// Propagates JSON-RPC errors raised by the venue and transport-level
943    /// failures.
944    pub async fn unsubscribe_ticker(&self, instrument_name: &str, interval: &str) -> Result<()> {
945        let channel = ticker_channel(instrument_name, interval);
946        self.send_unsubscribe(channel).await
947    }
948
949    /// Subscribes to `orderbook.{instrument_name}.{group}.{depth}`.
950    ///
951    /// # Errors
952    ///
953    /// Propagates JSON-RPC errors raised by the venue and transport-level
954    /// failures.
955    pub async fn subscribe_orderbook(
956        &self,
957        instrument_name: &str,
958        group: &str,
959        depth: &str,
960    ) -> Result<()> {
961        let channel = orderbook_channel(instrument_name, group, depth);
962        let params = orderbook_subscribe_params(instrument_name, group, depth);
963        self.send_subscribe(channel, &params).await
964    }
965
966    /// Unsubscribes from `orderbook.{instrument_name}.{group}.{depth}`.
967    ///
968    /// # Errors
969    ///
970    /// Propagates JSON-RPC errors raised by the venue and transport-level
971    /// failures.
972    pub async fn unsubscribe_orderbook(
973        &self,
974        instrument_name: &str,
975        group: &str,
976        depth: &str,
977    ) -> Result<()> {
978        let channel = orderbook_channel(instrument_name, group, depth);
979        self.send_unsubscribe(channel).await
980    }
981
982    /// Subscribes to `trades.{instrument_type}.{currency}`.
983    ///
984    /// # Errors
985    ///
986    /// Propagates JSON-RPC errors raised by the venue and transport-level
987    /// failures.
988    pub async fn subscribe_trades(&self, instrument_type: &str, currency: &str) -> Result<()> {
989        let channel = trades_channel(instrument_type, currency);
990        let params = trades_subscribe_params(instrument_type, currency);
991        self.send_subscribe(channel, &params).await
992    }
993
994    /// Unsubscribes from `trades.{instrument_type}.{currency}`.
995    ///
996    /// # Errors
997    ///
998    /// Propagates JSON-RPC errors raised by the venue and transport-level
999    /// failures.
1000    pub async fn unsubscribe_trades(&self, instrument_type: &str, currency: &str) -> Result<()> {
1001        let channel = trades_channel(instrument_type, currency);
1002        self.send_unsubscribe(channel).await
1003    }
1004
1005    /// Subscribes to multiple channel topics in a single `subscribe` frame.
1006    ///
1007    /// # Errors
1008    ///
1009    /// Propagates JSON-RPC errors raised by the venue and transport-level
1010    /// failures.
1011    pub async fn subscribe_channels<C>(&self, channels: Vec<C>) -> Result<()>
1012    where
1013        C: Into<DeriveWsChannel>,
1014    {
1015        let channels = channels.into_iter().map(Into::into).collect::<Vec<_>>();
1016        if channels.is_empty() {
1017            return Ok(());
1018        }
1019        let _guard = self.subscription_lock.lock().await;
1020        let params = WsSubscribeParams { channels };
1021        let cmd_tx = self.cmd_tx.read().await.clone();
1022        let result: WsSubscribeResult = send_request(
1023            &self.rate_limiter,
1024            &cmd_tx,
1025            methods::PUBLIC_SUBSCRIBE,
1026            &params,
1027            self.request_timeout,
1028        )
1029        .await?;
1030
1031        let (confirmed, failure) =
1032            subscription_outcome(&params.channels, &result, SUBSCRIPTION_ACCEPTED_STATUSES);
1033
1034        for channel in confirmed {
1035            self.subscriptions.insert(channel, ());
1036        }
1037        failure.map_or(Ok(()), Err)
1038    }
1039
1040    /// Unsubscribes from multiple channel topics in a single
1041    /// `unsubscribe` frame.
1042    ///
1043    /// # Errors
1044    ///
1045    /// Propagates JSON-RPC errors raised by the venue and transport-level
1046    /// failures.
1047    pub async fn unsubscribe_channels<C>(&self, channels: Vec<C>) -> Result<()>
1048    where
1049        C: Into<DeriveWsChannel>,
1050    {
1051        let channels = channels.into_iter().map(Into::into).collect::<Vec<_>>();
1052        if channels.is_empty() {
1053            return Ok(());
1054        }
1055        let _guard = self.subscription_lock.lock().await;
1056        let topics = channel_topics(&channels);
1057        let params = WsUnsubscribeParams { channels };
1058        let cmd_tx = self.cmd_tx.read().await.clone();
1059        let _: WsUnsubscribeResult = send_request(
1060            &self.rate_limiter,
1061            &cmd_tx,
1062            methods::PUBLIC_UNSUBSCRIBE,
1063            &params,
1064            self.request_timeout,
1065        )
1066        .await?;
1067
1068        for channel in topics {
1069            self.subscriptions.remove(&channel);
1070        }
1071
1072        Ok(())
1073    }
1074
1075    async fn send_subscribe(&self, channel: String, params: &WsSubscribeParams) -> Result<()> {
1076        let _guard = self.subscription_lock.lock().await;
1077        let cmd_tx = self.cmd_tx.read().await.clone();
1078        let result: WsSubscribeResult = send_request(
1079            &self.rate_limiter,
1080            &cmd_tx,
1081            methods::PUBLIC_SUBSCRIBE,
1082            params,
1083            self.request_timeout,
1084        )
1085        .await?;
1086
1087        let (confirmed, failure) =
1088            subscription_outcome(&params.channels, &result, SUBSCRIPTION_ACCEPTED_STATUSES);
1089
1090        if confirmed.iter().any(|topic| topic == &channel) {
1091            self.subscriptions.insert(channel, ());
1092        }
1093        failure.map_or(Ok(()), Err)
1094    }
1095
1096    async fn send_unsubscribe(&self, channel: String) -> Result<()> {
1097        let _guard = self.subscription_lock.lock().await;
1098        let params = WsUnsubscribeParams {
1099            channels: vec![DeriveWsChannel::from(channel.clone())],
1100        };
1101        let cmd_tx = self.cmd_tx.read().await.clone();
1102        let _: WsUnsubscribeResult = send_request(
1103            &self.rate_limiter,
1104            &cmd_tx,
1105            methods::PUBLIC_UNSUBSCRIBE,
1106            &params,
1107            self.request_timeout,
1108        )
1109        .await?;
1110
1111        self.subscriptions.remove(&channel);
1112
1113        Ok(())
1114    }
1115}
1116
1117impl DeriveWsExecutionHandle {
1118    /// Returns the current WebSocket connection id used by trigger orders.
1119    #[must_use]
1120    pub fn conn_id(&self) -> String {
1121        self.conn_id.load_full().as_ref().clone()
1122    }
1123
1124    /// Submits a signed order via `private/order`.
1125    ///
1126    /// `params` must be the fully-built signed body from
1127    /// [`crate::http::query::order_to_derive_payload`]. Returns the accepted
1128    /// order echoed by the venue.
1129    ///
1130    /// # Errors
1131    ///
1132    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1133    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1134    /// outcome is ambiguous.
1135    pub async fn submit_order(&self, params: &DeriveOrderParams) -> Result<DeriveOrder> {
1136        let reservation = self
1137            .reserve_matching_request(methods::PRIVATE_ORDER, &params.instrument_name)
1138            .await?;
1139        self.submit_order_after_rate_limit(params, reservation)
1140            .await
1141    }
1142
1143    pub(crate) async fn submit_order_after_rate_limit(
1144        &self,
1145        params: &DeriveOrderParams,
1146        reservation: MatchingRateLimitReservation,
1147    ) -> Result<DeriveOrder> {
1148        self.ensure_authenticated(methods::PRIVATE_ORDER)?;
1149        debug_assert_eq!(reservation.method, methods::PRIVATE_ORDER);
1150        self.refresh_matching_reservation(&reservation).await;
1151        let cmd_tx = self.cmd_tx.read().await.clone();
1152        let result: DeriveOrderResult = send_request_typed_after_rate_limit(
1153            &self.rate_limiter,
1154            &cmd_tx,
1155            methods::PRIVATE_ORDER,
1156            params,
1157            self.request_timeout,
1158        )
1159        .await?;
1160        Ok(result.order)
1161    }
1162
1163    /// Submits a signed trigger order via `private/trigger_order`.
1164    ///
1165    /// # Errors
1166    ///
1167    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1168    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1169    /// outcome is ambiguous.
1170    pub async fn submit_trigger_order(
1171        &self,
1172        params: &DeriveTriggerOrderParams,
1173    ) -> Result<DeriveOrder> {
1174        let reservation = self
1175            .reserve_matching_request(
1176                methods::PRIVATE_TRIGGER_ORDER,
1177                &params.order.instrument_name,
1178            )
1179            .await?;
1180        self.submit_trigger_order_after_rate_limit(params, reservation)
1181            .await
1182    }
1183
1184    pub(crate) async fn submit_trigger_order_after_rate_limit(
1185        &self,
1186        params: &DeriveTriggerOrderParams,
1187        reservation: MatchingRateLimitReservation,
1188    ) -> Result<DeriveOrder> {
1189        self.ensure_authenticated(methods::PRIVATE_TRIGGER_ORDER)?;
1190        debug_assert_eq!(reservation.method, methods::PRIVATE_TRIGGER_ORDER);
1191        self.refresh_matching_reservation(&reservation).await;
1192        let cmd_tx = self.cmd_tx.read().await.clone();
1193        let result: DeriveOrderResult = send_request_typed_after_rate_limit(
1194            &self.rate_limiter,
1195            &cmd_tx,
1196            methods::PRIVATE_TRIGGER_ORDER,
1197            params,
1198            self.request_timeout,
1199        )
1200        .await?;
1201        Ok(result.order)
1202    }
1203
1204    /// Modifies a working order by cancelling it and submitting a replacement
1205    /// through the venue's `private/replace`.
1206    ///
1207    /// # Errors
1208    ///
1209    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1210    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1211    /// outcome is ambiguous.
1212    pub async fn modify_order(&self, params: &DeriveReplaceParams) -> Result<DeriveReplaceOutcome> {
1213        let reservation = self
1214            .reserve_matching_request(methods::PRIVATE_REPLACE, &params.order.instrument_name)
1215            .await?;
1216        self.modify_order_after_rate_limit(params, reservation)
1217            .await
1218    }
1219
1220    pub(crate) async fn modify_order_after_rate_limit(
1221        &self,
1222        params: &DeriveReplaceParams,
1223        reservation: MatchingRateLimitReservation,
1224    ) -> Result<DeriveReplaceOutcome> {
1225        self.ensure_authenticated(methods::PRIVATE_REPLACE)?;
1226        debug_assert_eq!(reservation.method, methods::PRIVATE_REPLACE);
1227        self.refresh_matching_reservation(&reservation).await;
1228        let cmd_tx = self.cmd_tx.read().await.clone();
1229        let result: DeriveReplaceResult = send_request_typed_after_rate_limit(
1230            &self.rate_limiter,
1231            &cmd_tx,
1232            methods::PRIVATE_REPLACE,
1233            params,
1234            self.request_timeout,
1235        )
1236        .await?;
1237        result
1238            .into_outcome(&params.order_id_to_cancel, &params.order.label)
1239            .map_err(|message| {
1240                DeriveWsError::Serde(<serde_json::Error as serde::de::Error>::custom(message))
1241            })
1242    }
1243
1244    /// Cancels a single order via `private/cancel`.
1245    ///
1246    /// # Errors
1247    ///
1248    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1249    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1250    /// outcome is ambiguous.
1251    pub async fn cancel_order(&self, params: &DeriveCancelParams) -> Result<()> {
1252        self.require_authenticated(methods::PRIVATE_CANCEL).await?;
1253        let cmd_tx = self.cmd_tx.read().await.clone();
1254        let _: DeriveEmptyResult = send_request_for_instrument(
1255            &self.rate_limiter,
1256            &cmd_tx,
1257            methods::PRIVATE_CANCEL,
1258            params,
1259            self.request_timeout,
1260            params.instrument_name,
1261        )
1262        .await?;
1263        Ok(())
1264    }
1265
1266    /// Cancels every open order for one instrument via `private/cancel_by_instrument`.
1267    ///
1268    /// # Errors
1269    ///
1270    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1271    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1272    /// outcome is ambiguous.
1273    pub async fn cancel_by_instrument(
1274        &self,
1275        params: &DeriveCancelByInstrumentParams,
1276    ) -> Result<DeriveCancelByInstrumentResult> {
1277        self.require_authenticated(methods::PRIVATE_CANCEL_BY_INSTRUMENT)
1278            .await?;
1279        let cmd_tx = self.cmd_tx.read().await.clone();
1280        send_request_typed_for_instrument(
1281            &self.rate_limiter,
1282            &cmd_tx,
1283            methods::PRIVATE_CANCEL_BY_INSTRUMENT,
1284            params,
1285            self.request_timeout,
1286            params.instrument_name,
1287        )
1288        .await
1289    }
1290
1291    /// Cancels a single trigger order via `private/cancel_trigger_order`.
1292    ///
1293    /// # Errors
1294    ///
1295    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1296    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1297    /// outcome is ambiguous.
1298    pub async fn cancel_trigger_order(
1299        &self,
1300        params: &DeriveCancelTriggerOrderParams,
1301    ) -> Result<DeriveOrder> {
1302        self.require_authenticated(methods::PRIVATE_CANCEL_TRIGGER_ORDER)
1303            .await?;
1304        let cmd_tx = self.cmd_tx.read().await.clone();
1305        send_request_typed(
1306            &self.rate_limiter,
1307            &cmd_tx,
1308            methods::PRIVATE_CANCEL_TRIGGER_ORDER,
1309            params,
1310            self.request_timeout,
1311        )
1312        .await
1313    }
1314
1315    /// Cancels every open order with the given label via
1316    /// `private/cancel_by_label`.
1317    ///
1318    /// # Errors
1319    ///
1320    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1321    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1322    /// outcome is ambiguous.
1323    pub async fn cancel_by_label(
1324        &self,
1325        params: &DeriveCancelByLabelParams,
1326    ) -> Result<DeriveCancelByLabelResult> {
1327        self.require_authenticated(methods::PRIVATE_CANCEL_BY_LABEL)
1328            .await?;
1329        let cmd_tx = self.cmd_tx.read().await.clone();
1330        send_request_typed(
1331            &self.rate_limiter,
1332            &cmd_tx,
1333            methods::PRIVATE_CANCEL_BY_LABEL,
1334            params,
1335            self.request_timeout,
1336        )
1337        .await
1338    }
1339
1340    /// Returns currently untriggered trigger orders via
1341    /// `private/get_trigger_orders`.
1342    ///
1343    /// # Errors
1344    ///
1345    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1346    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1347    /// outcome is ambiguous.
1348    pub async fn get_trigger_orders(
1349        &self,
1350        params: &DeriveGetTriggerOrdersParams,
1351    ) -> Result<DeriveOpenOrdersResult> {
1352        self.require_authenticated(methods::PRIVATE_GET_TRIGGER_ORDERS)
1353            .await?;
1354        let cmd_tx = self.cmd_tx.read().await.clone();
1355        send_request_typed(
1356            &self.rate_limiter,
1357            &cmd_tx,
1358            methods::PRIVATE_GET_TRIGGER_ORDERS,
1359            params,
1360            self.request_timeout,
1361        )
1362        .await
1363    }
1364
1365    /// Cancels every open order on the subaccount (the venue's
1366    /// `private/cancel_all`), optionally scoped to an instrument.
1367    ///
1368    /// # Errors
1369    ///
1370    /// Returns [`DeriveWsError::JsonRpc`] for venue rejections and
1371    /// [`DeriveWsError::Transport`] / [`DeriveWsError::Timeout`] when the
1372    /// outcome is ambiguous.
1373    pub async fn cancel_all_orders(&self, params: &DeriveCancelAllParams) -> Result<()> {
1374        self.require_authenticated(methods::PRIVATE_CANCEL_ALL)
1375            .await?;
1376        let cmd_tx = self.cmd_tx.read().await.clone();
1377        let _: DeriveEmptyResult = send_request(
1378            &self.rate_limiter,
1379            &cmd_tx,
1380            methods::PRIVATE_CANCEL_ALL,
1381            params,
1382            self.request_timeout,
1383        )
1384        .await?;
1385        Ok(())
1386    }
1387
1388    pub(crate) async fn reserve_matching_request(
1389        &self,
1390        operation: &'static str,
1391        instrument_name: &Ustr,
1392    ) -> Result<MatchingRateLimitReservation> {
1393        self.require_authenticated(operation).await?;
1394        debug_assert_eq!(rate_class_for_method(operation), RateClass::Matching);
1395        let window = self
1396            .rate_limiter
1397            .await_class_ready(RateClass::Matching, Some(instrument_name))
1398            .await;
1399        self.ensure_authenticated(operation)?;
1400        Ok(MatchingRateLimitReservation {
1401            method: operation,
1402            instrument_name: *instrument_name,
1403            window,
1404        })
1405    }
1406
1407    // Signing between the reservation and the reserved send can cross a
1408    // window boundary; a rolled window re-acquires so the departure draws on
1409    // its own window's cells.
1410    async fn refresh_matching_reservation(&self, reservation: &MatchingRateLimitReservation) {
1411        self.rate_limiter
1412            .ensure_window_current(
1413                RateClass::Matching,
1414                Some(&reservation.instrument_name),
1415                reservation.window,
1416            )
1417            .await;
1418    }
1419
1420    fn ensure_authenticated(&self, operation: &'static str) -> Result<()> {
1421        if self.auth_tracker.is_authenticated() {
1422            return Ok(());
1423        }
1424
1425        Err(DeriveWsError::Authentication {
1426            operation: operation.to_string(),
1427            reason: "WebSocket session is not authenticated".to_string(),
1428        })
1429    }
1430
1431    async fn require_authenticated(&self, operation: &'static str) -> Result<()> {
1432        if self
1433            .auth_tracker
1434            .wait_for_authenticated(self.request_timeout)
1435            .await
1436        {
1437            return Ok(());
1438        }
1439
1440        Err(DeriveWsError::Authentication {
1441            operation: operation.to_string(),
1442            reason: "WebSocket session is not authenticated".to_string(),
1443        })
1444    }
1445}
1446
1447// Awaits the venue's raw `result`, bounded by `timeout`. A dropped responder
1448// (handler torn down on reconnect) surfaces as `RequestCancelled`, a timeout as
1449// `Timeout`; both leave a state-changing write's outcome ambiguous.
1450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1451enum RequestRateLimit {
1452    /// Pace the request now, against the class buckets plus the carried
1453    /// instrument's per-instrument bucket when present.
1454    Await(Option<Ustr>),
1455    /// A matching reservation already consumed the cells; do not pace or
1456    /// consume again.
1457    Reserved,
1458}
1459
1460async fn send_raw<P>(
1461    rate_limiter: &WsRateLimiter,
1462    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1463    method: &'static str,
1464    params: &P,
1465    timeout: Duration,
1466) -> Result<Value>
1467where
1468    P: Serialize + ?Sized,
1469{
1470    send_raw_with_rate_limit(
1471        rate_limiter,
1472        cmd_tx,
1473        method,
1474        params,
1475        timeout,
1476        RequestRateLimit::Await(None),
1477        None,
1478    )
1479    .await
1480}
1481
1482// Awaits the venue's raw `result` for a matching write that carries an
1483// instrument, pacing it against the account-wide and per-instrument buckets.
1484async fn send_raw_for_instrument<P>(
1485    rate_limiter: &WsRateLimiter,
1486    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1487    method: &'static str,
1488    params: &P,
1489    timeout: Duration,
1490    instrument_name: Ustr,
1491) -> Result<Value>
1492where
1493    P: Serialize + ?Sized,
1494{
1495    send_raw_with_rate_limit(
1496        rate_limiter,
1497        cmd_tx,
1498        method,
1499        params,
1500        timeout,
1501        RequestRateLimit::Await(Some(instrument_name)),
1502        None,
1503    )
1504    .await
1505}
1506
1507async fn send_raw_after_rate_limit<P>(
1508    rate_limiter: &WsRateLimiter,
1509    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1510    method: &'static str,
1511    params: &P,
1512    timeout: Duration,
1513) -> Result<Value>
1514where
1515    P: Serialize + ?Sized,
1516{
1517    send_raw_with_rate_limit(
1518        rate_limiter,
1519        cmd_tx,
1520        method,
1521        params,
1522        timeout,
1523        RequestRateLimit::Reserved,
1524        None,
1525    )
1526    .await
1527}
1528
1529async fn send_raw_with_rate_limit<P>(
1530    rate_limiter: &WsRateLimiter,
1531    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1532    method: &'static str,
1533    params: &P,
1534    timeout: Duration,
1535    rate_limit: RequestRateLimit,
1536    connection_epoch: Option<u64>,
1537) -> Result<Value>
1538where
1539    P: Serialize + ?Sized,
1540{
1541    let params = serde_json::to_value(params)?;
1542
1543    if let RequestRateLimit::Await(instrument_name) = rate_limit {
1544        rate_limiter
1545            .await_class_ready(rate_class_for_method(method), instrument_name.as_ref())
1546            .await;
1547    }
1548
1549    let (response_tx, response_rx) = tokio::sync::oneshot::channel();
1550    cmd_tx
1551        .send(HandlerCommand::Request {
1552            method,
1553            params,
1554            connection_epoch,
1555            response_tx,
1556        })
1557        .map_err(|e| DeriveWsError::transport(format!("failed to enqueue `{method}`: {e}")))?;
1558
1559    // On timeout the handler's `pending` entry leaks until the next reconnect's
1560    // `fail_pending` drains it; the later send to the dropped receiver is a
1561    // no-op logged at debug.
1562    match tokio::time::timeout(timeout, response_rx).await {
1563        Ok(Ok(outcome)) => outcome,
1564        Ok(Err(_)) => Err(DeriveWsError::RequestCancelled {
1565            method: method.to_owned(),
1566        }),
1567        Err(_) => Err(DeriveWsError::Timeout {
1568            method: method.to_owned(),
1569        }),
1570    }
1571}
1572
1573// Decodes the result, treating a null/absent `result` as `R::default()` (for
1574// login/subscribe/unsubscribe and the cancel family's `DeriveEmptyResult`).
1575async fn send_request<P, R>(
1576    rate_limiter: &WsRateLimiter,
1577    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1578    method: &'static str,
1579    params: &P,
1580    timeout: Duration,
1581) -> Result<R>
1582where
1583    P: Serialize + ?Sized,
1584    R: Default + DeserializeOwned,
1585{
1586    let value = send_raw(rate_limiter, cmd_tx, method, params, timeout).await?;
1587    decode_default_result(value)
1588}
1589
1590// Same as `send_request` for a matching write that carries an instrument, so
1591// the venue's per-instrument allowance is paced alongside the global one.
1592async fn send_request_for_instrument<P, R>(
1593    rate_limiter: &WsRateLimiter,
1594    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1595    method: &'static str,
1596    params: &P,
1597    timeout: Duration,
1598    instrument_name: Ustr,
1599) -> Result<R>
1600where
1601    P: Serialize + ?Sized,
1602    R: Default + DeserializeOwned,
1603{
1604    let value = send_raw_for_instrument(
1605        rate_limiter,
1606        cmd_tx,
1607        method,
1608        params,
1609        timeout,
1610        instrument_name,
1611    )
1612    .await?;
1613    decode_default_result(value)
1614}
1615
1616// Keep strict result decoding while reserving both matching buckets
1617async fn send_request_typed_for_instrument<P, R>(
1618    rate_limiter: &WsRateLimiter,
1619    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1620    method: &'static str,
1621    params: &P,
1622    timeout: Duration,
1623    instrument_name: Ustr,
1624) -> Result<R>
1625where
1626    P: Serialize + ?Sized,
1627    R: DeserializeOwned,
1628{
1629    let value = send_raw_for_instrument(
1630        rate_limiter,
1631        cmd_tx,
1632        method,
1633        params,
1634        timeout,
1635        instrument_name,
1636    )
1637    .await?;
1638    Ok(serde_json::from_value(value)?)
1639}
1640
1641fn decode_default_result<R>(value: Value) -> Result<R>
1642where
1643    R: Default + DeserializeOwned,
1644{
1645    if value.is_null() {
1646        Ok(R::default())
1647    } else {
1648        Ok(serde_json::from_value(value)?)
1649    }
1650}
1651
1652async fn send_request_on_connection<P, R>(
1653    rate_limiter: &WsRateLimiter,
1654    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1655    method: &'static str,
1656    params: &P,
1657    timeout: Duration,
1658    connection_epoch: u64,
1659) -> Result<R>
1660where
1661    P: Serialize + ?Sized,
1662    R: Default + DeserializeOwned,
1663{
1664    let value = send_raw_with_rate_limit(
1665        rate_limiter,
1666        cmd_tx,
1667        method,
1668        params,
1669        timeout,
1670        RequestRateLimit::Await(None),
1671        Some(connection_epoch),
1672    )
1673    .await?;
1674
1675    decode_default_result(value)
1676}
1677
1678// Decodes the result with no `Default` fallback, for `private/order` and
1679// `private/replace` whose success result is always a populated object.
1680async fn send_request_typed<P, R>(
1681    rate_limiter: &WsRateLimiter,
1682    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1683    method: &'static str,
1684    params: &P,
1685    timeout: Duration,
1686) -> Result<R>
1687where
1688    P: Serialize + ?Sized,
1689    R: DeserializeOwned,
1690{
1691    let value = send_raw(rate_limiter, cmd_tx, method, params, timeout).await?;
1692    Ok(serde_json::from_value(value)?)
1693}
1694
1695async fn send_request_typed_after_rate_limit<P, R>(
1696    rate_limiter: &WsRateLimiter,
1697    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1698    method: &'static str,
1699    params: &P,
1700    timeout: Duration,
1701) -> Result<R>
1702where
1703    P: Serialize + ?Sized,
1704    R: DeserializeOwned,
1705{
1706    let value = send_raw_after_rate_limit(rate_limiter, cmd_tx, method, params, timeout).await?;
1707    Ok(serde_json::from_value(value)?)
1708}
1709
1710fn channel_topics(channels: &[DeriveWsChannel]) -> Vec<String> {
1711    channels.iter().map(ToString::to_string).collect()
1712}
1713
1714#[expect(
1715    clippy::too_many_arguments,
1716    reason = "authentication state is passed explicitly for epoch fencing"
1717)]
1718async fn login_via_handler(
1719    rate_limiter: &WsRateLimiter,
1720    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1721    auth_tracker: &AuthTracker,
1722    authenticated_epoch: &AtomicU64,
1723    connection_mode: &AtomicU8,
1724    connection_epoch: &AtomicU64,
1725    creds: &DeriveWsCredentials,
1726    timeout: Duration,
1727) -> Result<()> {
1728    let _receiver = auth_tracker.begin();
1729    let expected_epoch = connection_epoch.load(Ordering::Acquire);
1730
1731    match send_login_request(rate_limiter, cmd_tx, creds, timeout, expected_epoch).await {
1732        Ok(())
1733            if complete_session_authentication(
1734                auth_tracker,
1735                authenticated_epoch,
1736                connection_mode,
1737                connection_epoch,
1738                expected_epoch,
1739            ) =>
1740        {
1741            log::debug!("Derive WebSocket authenticated");
1742
1743            Ok(())
1744        }
1745        Ok(()) => {
1746            let e = DeriveWsError::transport(
1747                "connection changed while completing WebSocket authentication",
1748            );
1749            authenticated_epoch.store(UNAUTHENTICATED_CONNECTION_EPOCH, Ordering::Release);
1750            auth_tracker.fail(e.to_string());
1751            Err(e)
1752        }
1753        Err(e) => {
1754            authenticated_epoch.store(UNAUTHENTICATED_CONNECTION_EPOCH, Ordering::Release);
1755            auth_tracker.fail(e.to_string());
1756            Err(e)
1757        }
1758    }
1759}
1760
1761async fn send_login_request(
1762    rate_limiter: &WsRateLimiter,
1763    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1764    creds: &DeriveWsCredentials,
1765    timeout: Duration,
1766    connection_epoch: u64,
1767) -> Result<()> {
1768    let login = build_ws_login(&creds.wallet_address, &creds.signer)?;
1769    let params = WsLoginParams {
1770        wallet: login.wallet,
1771        timestamp: login.timestamp,
1772        signature: login.signature,
1773    };
1774    let result = send_request_on_connection::<_, WsLoginResult>(
1775        rate_limiter,
1776        cmd_tx,
1777        methods::PUBLIC_LOGIN,
1778        &params,
1779        timeout,
1780        connection_epoch,
1781    )
1782    .await?;
1783
1784    if matches!(result, WsLoginResult::Success { success: false }) {
1785        return Err(DeriveWsError::Authentication {
1786            operation: methods::PUBLIC_LOGIN.to_string(),
1787            reason: "venue returned an unsuccessful login result".to_string(),
1788        });
1789    }
1790
1791    Ok(())
1792}
1793
1794#[expect(
1795    clippy::too_many_arguments,
1796    reason = "recovery state is passed explicitly for epoch fencing"
1797)]
1798async fn recover_session(
1799    rate_limiter: &WsRateLimiter,
1800    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1801    auth_tracker: &AuthTracker,
1802    authenticated_epoch: &AtomicU64,
1803    connection_mode: &AtomicU8,
1804    connection_epoch: &AtomicU64,
1805    creds: Option<&DeriveWsCredentials>,
1806    subscriptions: &DashMap<String, ()>,
1807    subscription_lock: &tokio::sync::Mutex<()>,
1808    timeout: Duration,
1809) -> Result<u64> {
1810    let _guard = subscription_lock.lock().await;
1811    let _receiver = creds.map(|_| auth_tracker.begin());
1812
1813    for attempt in 1..=MAX_SESSION_RECOVERY_ATTEMPTS {
1814        let expected_epoch = wait_for_session_connection(connection_mode, connection_epoch).await?;
1815
1816        let result = async {
1817            if !session_connection_is_active(connection_mode, connection_epoch, expected_epoch) {
1818                return Err(DeriveWsError::transport(
1819                    "connection changed before WebSocket session recovery",
1820                ));
1821            }
1822
1823            if let Some(creds) = creds {
1824                send_login_request(rate_limiter, cmd_tx, creds, timeout, expected_epoch).await?;
1825            }
1826            let channels: Vec<String> = subscriptions
1827                .iter()
1828                .map(|entry| entry.key().clone())
1829                .collect();
1830            subscribe_via_handler(rate_limiter, cmd_tx, channels, timeout, expected_epoch).await?;
1831
1832            if !session_connection_is_active(connection_mode, connection_epoch, expected_epoch) {
1833                return Err(DeriveWsError::transport(
1834                    "connection changed during WebSocket session recovery",
1835                ));
1836            }
1837
1838            Ok(())
1839        }
1840        .await;
1841
1842        match result {
1843            Ok(()) => {
1844                if creds.is_some()
1845                    && !complete_session_authentication(
1846                        auth_tracker,
1847                        authenticated_epoch,
1848                        connection_mode,
1849                        connection_epoch,
1850                        expected_epoch,
1851                    )
1852                {
1853                    continue;
1854                }
1855
1856                if creds.is_some() {
1857                    log::info!("Derive WebSocket session re-authenticated");
1858                }
1859
1860                return Ok(expected_epoch);
1861            }
1862            Err(e) if attempt < MAX_SESSION_RECOVERY_ATTEMPTS => {
1863                let multiplier = 1_u32 << (attempt - 1);
1864                let delay = RECONNECT_BASE_BACKOFF
1865                    .saturating_mul(multiplier)
1866                    .min(RECONNECT_MAX_BACKOFF);
1867                log::warn!(
1868                    "Derive WebSocket session recovery attempt {attempt}/{MAX_SESSION_RECOVERY_ATTEMPTS} failed: {e}; retrying in {delay:?}",
1869                );
1870                tokio::time::sleep(delay).await;
1871            }
1872            Err(e) => {
1873                authenticated_epoch.store(UNAUTHENTICATED_CONNECTION_EPOCH, Ordering::Release);
1874
1875                if creds.is_some() {
1876                    auth_tracker.fail(e.to_string());
1877                }
1878                return Err(e);
1879            }
1880        }
1881    }
1882
1883    let e = DeriveWsError::transport("WebSocket session changed while recovery completed");
1884    authenticated_epoch.store(UNAUTHENTICATED_CONNECTION_EPOCH, Ordering::Release);
1885
1886    if creds.is_some() {
1887        auth_tracker.fail(e.to_string());
1888    }
1889    Err(e)
1890}
1891
1892async fn wait_for_session_connection(
1893    connection_mode: &AtomicU8,
1894    connection_epoch: &AtomicU64,
1895) -> Result<u64> {
1896    loop {
1897        match ConnectionMode::from_atomic(connection_mode) {
1898            ConnectionMode::Active => return Ok(connection_epoch.load(Ordering::Acquire)),
1899            ConnectionMode::Reconnect => tokio::time::sleep(RECONNECT_BASE_BACKOFF).await,
1900            ConnectionMode::Disconnect | ConnectionMode::Closed => {
1901                return Err(DeriveWsError::transport(
1902                    "WebSocket closed during session recovery",
1903                ));
1904            }
1905        }
1906    }
1907}
1908
1909fn complete_session_authentication(
1910    auth_tracker: &AuthTracker,
1911    authenticated_epoch: &AtomicU64,
1912    connection_mode: &AtomicU8,
1913    connection_epoch: &AtomicU64,
1914    expected_epoch: u64,
1915) -> bool {
1916    if !session_connection_is_active(connection_mode, connection_epoch, expected_epoch) {
1917        return false;
1918    }
1919
1920    authenticated_epoch.store(expected_epoch, Ordering::Release);
1921    auth_tracker.succeed();
1922
1923    if session_connection_is_active(connection_mode, connection_epoch, expected_epoch) {
1924        true
1925    } else {
1926        authenticated_epoch.store(UNAUTHENTICATED_CONNECTION_EPOCH, Ordering::Release);
1927        auth_tracker.invalidate();
1928        false
1929    }
1930}
1931
1932fn session_connection_is_active(
1933    connection_mode: &AtomicU8,
1934    connection_epoch: &AtomicU64,
1935    expected_epoch: u64,
1936) -> bool {
1937    ConnectionMode::from_atomic(connection_mode).is_active()
1938        && connection_epoch.load(Ordering::Acquire) == expected_epoch
1939}
1940
1941async fn subscribe_via_handler(
1942    rate_limiter: &WsRateLimiter,
1943    cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1944    channels: Vec<String>,
1945    timeout: Duration,
1946    connection_epoch: u64,
1947) -> Result<()> {
1948    if channels.is_empty() {
1949        return Ok(());
1950    }
1951
1952    let params = WsSubscribeParams {
1953        channels: channels.into_iter().map(DeriveWsChannel::from).collect(),
1954    };
1955    let result: WsSubscribeResult = send_request_on_connection(
1956        rate_limiter,
1957        cmd_tx,
1958        methods::PUBLIC_SUBSCRIBE,
1959        &params,
1960        timeout,
1961        connection_epoch,
1962    )
1963    .await?;
1964
1965    let (_, failure) = subscription_outcome(
1966        &params.channels,
1967        &result,
1968        SUBSCRIPTION_REPLAY_ACCEPTED_STATUSES,
1969    );
1970    failure.map_or(Ok(()), Err)
1971}
1972
1973fn subscription_outcome(
1974    requested: &[DeriveWsChannel],
1975    result: &WsSubscribeResult,
1976    accepted_statuses: &[&str],
1977) -> (Vec<String>, Option<DeriveWsError>) {
1978    let mut confirmed = Vec::with_capacity(requested.len());
1979    let mut failures = Vec::new();
1980
1981    for channel in requested {
1982        let topic = channel.to_string();
1983        match result.status.get(channel) {
1984            Some(status) if accepted_statuses.contains(&status.as_str()) => confirmed.push(topic),
1985            Some(status) => failures.push(format!("{topic}: {status}")),
1986            None if result.channels.contains(channel) => confirmed.push(topic),
1987            None => failures.push(format!("{topic}: missing channel status")),
1988        }
1989    }
1990
1991    let failure = (!failures.is_empty()).then(|| DeriveWsError::Subscription {
1992        details: failures.join(", "),
1993    });
1994    (confirmed, failure)
1995}
1996
1997#[cfg(test)]
1998mod tests {
1999    use rstest::rstest;
2000
2001    use super::*;
2002    use crate::common::rate_limit::RateBucket;
2003
2004    #[rstest]
2005    fn test_public_client_defaults_to_environment_url() {
2006        let client = DeriveWebSocketClient::new(
2007            None,
2008            DeriveEnvironment::Mainnet,
2009            TransportBackend::default(),
2010            None,
2011        );
2012        assert!(client.url().starts_with("wss://"));
2013        assert!(client.url().contains("api.lyra.finance"));
2014        assert!(!client.is_authenticated());
2015        assert!(!client.is_active());
2016        assert_eq!(client.subscription_count(), 0);
2017    }
2018
2019    #[tokio::test]
2020    async fn test_execution_auth_barrier_waits_for_authentication() {
2021        let client = DeriveWebSocketClient::with_credentials(
2022            None,
2023            DeriveEnvironment::Mainnet,
2024            TransportBackend::default(),
2025            None,
2026            DeriveWsCredentials::new(
2027                "0x000000000000000000000000000000000000aaaa",
2028                "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd",
2029            )
2030            .unwrap(),
2031            None,
2032            None,
2033        );
2034        let execution = client.execution_handle();
2035        let auth_tracker = execution.auth_tracker.clone();
2036        let _receiver = auth_tracker.begin();
2037        let tracker_for_task = auth_tracker.clone();
2038
2039        get_runtime().spawn(async move {
2040            tokio::time::sleep(Duration::from_millis(10)).await;
2041            tracker_for_task.succeed();
2042        });
2043
2044        execution
2045            .require_authenticated(methods::PRIVATE_ORDER)
2046            .await
2047            .expect("barrier should wait for successful authentication");
2048    }
2049
2050    #[tokio::test]
2051    async fn test_execution_auth_barrier_fails_on_terminal_auth_failure() {
2052        let client = DeriveWebSocketClient::with_credentials(
2053            None,
2054            DeriveEnvironment::Mainnet,
2055            TransportBackend::default(),
2056            None,
2057            DeriveWsCredentials::new(
2058                "0x000000000000000000000000000000000000aaaa",
2059                "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd",
2060            )
2061            .unwrap(),
2062            None,
2063            None,
2064        );
2065        let execution = client.execution_handle();
2066        let _receiver = execution.auth_tracker.begin();
2067        execution.auth_tracker.fail("bad signature");
2068
2069        let error = execution
2070            .require_authenticated(methods::PRIVATE_ORDER)
2071            .await
2072            .expect_err("terminal auth failure must reject private operations");
2073
2074        assert!(matches!(error, DeriveWsError::Authentication { .. }));
2075    }
2076
2077    #[tokio::test]
2078    async fn test_session_recovery_waits_for_reconnecting_transport() {
2079        let connection_mode = Arc::new(AtomicU8::new(ConnectionMode::Reconnect as u8));
2080        let connection_epoch = Arc::new(AtomicU64::new(1));
2081        let mode_for_task = Arc::clone(&connection_mode);
2082        let epoch_for_task = Arc::clone(&connection_epoch);
2083
2084        get_runtime().spawn(async move {
2085            tokio::time::sleep(Duration::from_millis(10)).await;
2086            epoch_for_task.store(2, Ordering::Release);
2087            mode_for_task.store(ConnectionMode::Active as u8, Ordering::Release);
2088        });
2089
2090        let epoch = tokio::time::timeout(
2091            Duration::from_secs(1),
2092            wait_for_session_connection(&connection_mode, &connection_epoch),
2093        )
2094        .await
2095        .expect("session recovery should resume after reconnect")
2096        .expect("active replacement connection should be accepted");
2097
2098        assert_eq!(epoch, 2);
2099    }
2100
2101    #[rstest]
2102    fn test_testnet_client_routes_to_demo_url() {
2103        let client = DeriveWebSocketClient::new(
2104            None,
2105            DeriveEnvironment::Testnet,
2106            TransportBackend::default(),
2107            None,
2108        );
2109        assert!(client.url().contains("demo"));
2110    }
2111
2112    #[rstest]
2113    fn test_credentials_constructor_parses_session_key() {
2114        let creds = DeriveWsCredentials::new(
2115            "0x000000000000000000000000000000000000aaaa",
2116            "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd",
2117        )
2118        .unwrap();
2119        assert!(creds.wallet_address.starts_with("0x"));
2120        let client = DeriveWebSocketClient::with_credentials(
2121            None,
2122            DeriveEnvironment::Testnet,
2123            TransportBackend::default(),
2124            None,
2125            creds,
2126            None,
2127            None,
2128        );
2129        assert!(client.url().contains("demo"));
2130        assert!(!client.is_authenticated());
2131    }
2132
2133    #[rstest]
2134    fn test_credentials_debug_redacts_signer() {
2135        let creds = DeriveWsCredentials::new(
2136            "0xWALLET",
2137            "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd",
2138        )
2139        .unwrap();
2140        let debug = format!("{creds:?}");
2141        assert!(debug.contains("redacted"));
2142        assert!(debug.contains("0xWALLET"));
2143        assert!(!debug.contains("2ae8be44"));
2144    }
2145
2146    #[rstest]
2147    fn test_credentials_constructor_rejects_invalid_session_key() {
2148        let err = DeriveWsCredentials::new("0xWALLET", "not-a-hex-key").unwrap_err();
2149        assert!(err.to_string().contains("invalid session key"));
2150    }
2151
2152    #[rstest]
2153    #[tokio::test]
2154    async fn test_send_raw_times_out_when_no_response_arrives() {
2155        // Keep the receiver alive so the request enqueues, but never reply: the
2156        // bounded await must surface a Timeout rather than hang forever.
2157        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2158        let rate_limiter: WsRateLimiter =
2159            FixedWindowLimiter::new(FixedWindowLimits::websocket(None, None), MonotonicClock {});
2160        let err = send_raw(
2161            &rate_limiter,
2162            &cmd_tx,
2163            methods::PRIVATE_ORDER,
2164            &serde_json::json!({}),
2165            Duration::from_millis(50),
2166        )
2167        .await
2168        .expect_err("must time out");
2169
2170        match err {
2171            DeriveWsError::Timeout { method } => assert_eq!(method, methods::PRIVATE_ORDER),
2172            other => panic!("expected Timeout, was {other:?}"),
2173        }
2174    }
2175
2176    #[rstest]
2177    #[tokio::test]
2178    async fn test_send_request_typed_rejects_null_result() {
2179        // `private/order` and `private/replace` always return a populated
2180        // object on success; a null result is a protocol violation that must
2181        // surface as a serde error (classified ambiguous by the exec client).
2182        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2183        tokio::spawn(async move {
2184            if let Some(HandlerCommand::Request { response_tx, .. }) = cmd_rx.recv().await {
2185                let _ = response_tx.send(Ok(Value::Null));
2186            }
2187        });
2188        let rate_limiter: WsRateLimiter =
2189            FixedWindowLimiter::new(FixedWindowLimits::websocket(None, None), MonotonicClock {});
2190        let result: Result<DeriveOrderResult> = send_request_typed(
2191            &rate_limiter,
2192            &cmd_tx,
2193            methods::PRIVATE_ORDER,
2194            &serde_json::json!({}),
2195            Duration::from_secs(1),
2196        )
2197        .await;
2198        assert!(matches!(result, Err(DeriveWsError::Serde(_))));
2199    }
2200
2201    #[rstest]
2202    #[tokio::test]
2203    async fn test_reserved_send_does_not_wait_for_or_consume_second_quota_cell() {
2204        let rate_limiter: WsRateLimiter =
2205            FixedWindowLimiter::new(FixedWindowLimits::websocket(None, None), MonotonicClock {});
2206
2207        for _ in 0..5 {
2208            rate_limiter
2209                .check_bucket(RateBucket::Matching)
2210                .expect("reservation consumes the matching window");
2211        }
2212        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2213        tokio::spawn(async move {
2214            if let Some(HandlerCommand::Request { response_tx, .. }) = cmd_rx.recv().await {
2215                let _ = response_tx.send(Ok(serde_json::json!({"accepted": true})));
2216            }
2217        });
2218
2219        let response = tokio::time::timeout(
2220            Duration::from_millis(100),
2221            send_raw_after_rate_limit(
2222                &rate_limiter,
2223                &cmd_tx,
2224                methods::PRIVATE_ORDER,
2225                &serde_json::json!({}),
2226                Duration::from_secs(1),
2227            ),
2228        )
2229        .await
2230        .expect("reserved send must not wait for quota")
2231        .expect("reserved send succeeds");
2232
2233        assert_eq!(response, serde_json::json!({"accepted": true}));
2234        assert!(
2235            rate_limiter.check_bucket(RateBucket::Matching).is_err(),
2236            "reserved send must not consume a second cell",
2237        );
2238    }
2239}