Skip to main content

nautilus_hyperliquid/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
16use std::{
17    str::FromStr,
18    sync::{
19        Arc,
20        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
21    },
22    time::Duration,
23};
24
25use ahash::{AHashMap, AHashSet};
26use anyhow::Context;
27use arc_swap::ArcSwap;
28use dashmap::DashMap;
29use nautilus_common::cache::{InstrumentLookupError, fifo::FifoCacheMap};
30#[cfg(test)]
31use nautilus_common::live::get_runtime;
32#[cfg(test)]
33use nautilus_core::string::secret::REDACTED;
34use nautilus_core::{AtomicMap, string::secret::SecretString};
35use nautilus_live::{
36    SocketControl,
37    book::snapshot::SnapshotGate,
38    task::{SharedTaskSlot, TaskGroupGuard, TaskJoinOutcome},
39};
40use nautilus_model::{
41    data::BarType,
42    enums::{OrderSide, OrderType, TimeInForce},
43    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
44    instruments::{Instrument, InstrumentAny},
45    orders::{Order, OrderAny},
46    reports::OrderStatusReport,
47    types::{Price, Quantity},
48};
49use nautilus_network::{
50    ConnectionUrl, SocketStateSink,
51    http::create_standard_nautilus_headers,
52    mode::ConnectionMode,
53    websocket::{
54        AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
55        channel_message_handler,
56    },
57};
58use parking_lot::Mutex;
59use rust_decimal::Decimal;
60use serde::Deserialize;
61use tokio_util::sync::CancellationToken;
62use ustr::Ustr;
63
64use crate::{
65    common::{
66        consts::{HEARTBEAT_INTERVAL, HTTP_TIMEOUT, ws_url},
67        enums::{HyperliquidBarInterval, HyperliquidEnvironment},
68        parse::{
69            bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
70            determine_order_list_grouping, extract_error_message, extract_inner_error,
71            extract_inner_errors, normalize_or_validate_wire_price,
72            order_to_hyperliquid_request_with_optional_decimals, round_to_sig_figs,
73            time_in_force_to_hyperliquid_tif,
74        },
75    },
76    http::{
77        client::HyperliquidHttpClient,
78        error::{Error as HyperliquidError, Result as HyperliquidResult},
79        models::{
80            HyperliquidExchangeAction, HyperliquidExchangeCancelByCloidRequest,
81            HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
82            HyperliquidExchangeLimitParams, HyperliquidExchangeModifyOrderRequest,
83            HyperliquidExchangeModifyTarget, HyperliquidExchangeOrderKind,
84            HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeResponse,
85            HyperliquidExchangeTif, HyperliquidExchangeTpSl, HyperliquidExchangeTriggerParams,
86            RESPONSE_STATUS_OK,
87        },
88        rate_limits::exec_action_weight,
89    },
90    websocket::{
91        book::{
92            BookStreamOptions, BookStreamRegistration, BookStreamRegistry, BookStreamRelease,
93            BookStreamUse,
94        },
95        enums::HyperliquidWsChannel,
96        error::HyperliquidWsError,
97        handler::{FeedHandler, HandlerCommand},
98        messages::{
99            NautilusWsMessage, PostRequest, PostResponse, PostResponsePayload, SubscriptionRequest,
100        },
101        post::{PostIds, PostRouter},
102        rate_limits::{WebSocketRateLimits, shared_websocket_limits},
103        trades::{TradeStreamRegistry, TradeStreamUse},
104    },
105};
106
107const RESPONSE_STATUS_ERR: &str = "err";
108
109static NEXT_WEBSOCKET_CLIENT_ID: AtomicU64 = AtomicU64::new(1);
110
111/// FIFO bound on the cloid -> `ClientOrderId` resolution cache so missed
112/// evictions self-recover (see GH-3972 cancel-replace drain path).
113pub(super) const CLOID_CACHE_CAPACITY: usize = 10_000;
114
115/// Shared cloid -> `ClientOrderId` cache used by the WS handler.
116pub(super) type CloidCache = Arc<Mutex<FifoCacheMap<Ustr, ClientOrderId, CLOID_CACHE_CAPACITY>>>;
117
118/// Represents the different data types available from asset context subscriptions.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120pub(super) enum AssetContextDataType {
121    MarkPrice,
122    IndexPrice,
123    FundingRate,
124    OpenInterest,
125}
126
127/// Hyperliquid WebSocket client.
128///
129/// Orchestrates WebSocket connection and subscriptions using a command-based architecture,
130/// where the inner FeedHandler owns the WebSocketClient and handles all I/O.
131#[derive(Debug)]
132pub struct HyperliquidWebSocketClient {
133    url: ConnectionUrl,
134    connection_mode: Arc<ArcSwap<AtomicU8>>,
135    signal: Arc<AtomicBool>,
136    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
137    out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
138    auth_tracker: AuthTracker,
139    subscriptions: SubscriptionState,
140    book_streams: BookStreamRegistry,
141    trade_streams: TradeStreamRegistry,
142    trade_stream_lock: Arc<Mutex<()>>,
143    quote_streams: Arc<DashMap<Ustr, ()>>,
144    instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
145    cache_tx: Arc<Mutex<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
146    bar_types: Arc<AtomicMap<String, BarType>>,
147    asset_context_subs: Arc<DashMap<Ustr, AHashSet<AssetContextDataType>>>,
148    all_dex_asset_ctxs_instrument_ids: Arc<AtomicMap<Ustr, Vec<Option<InstrumentId>>>>,
149    cloid_cache: CloidCache,
150    post_router: Arc<PostRouter>,
151    post_ids: Arc<PostIds>,
152    rate_limits: Arc<WebSocketRateLimits>,
153    client_id: u64,
154    connection_permit: Arc<Mutex<Option<tokio::sync::OwnedSemaphorePermit>>>,
155    post_timeout: Duration,
156    task_handle: Arc<SharedTaskSlot<()>>,
157    connect_lock: Arc<tokio::sync::Mutex<()>>,
158    account_id: Option<AccountId>,
159    transport_backend: TransportBackend,
160    proxy_url: Option<SecretString>,
161    socket_sink: Option<SocketStateSink>,
162    socket_control: Option<SocketControl>,
163}
164
165impl Clone for HyperliquidWebSocketClient {
166    fn clone(&self) -> Self {
167        Self {
168            url: self.url.clone(),
169            connection_mode: Arc::clone(&self.connection_mode),
170            signal: Arc::clone(&self.signal),
171            cmd_tx: Arc::clone(&self.cmd_tx),
172            out_rx: None,
173            auth_tracker: self.auth_tracker.clone(),
174            subscriptions: self.subscriptions.clone(),
175            book_streams: self.book_streams.clone(),
176            trade_streams: self.trade_streams.clone(),
177            trade_stream_lock: Arc::clone(&self.trade_stream_lock),
178            quote_streams: Arc::clone(&self.quote_streams),
179            instruments: Arc::clone(&self.instruments),
180            cache_tx: Arc::clone(&self.cache_tx),
181            bar_types: Arc::clone(&self.bar_types),
182            asset_context_subs: Arc::clone(&self.asset_context_subs),
183            all_dex_asset_ctxs_instrument_ids: Arc::clone(&self.all_dex_asset_ctxs_instrument_ids),
184            cloid_cache: Arc::clone(&self.cloid_cache),
185            post_router: Arc::clone(&self.post_router),
186            post_ids: Arc::clone(&self.post_ids),
187            rate_limits: Arc::clone(&self.rate_limits),
188            client_id: self.client_id,
189            connection_permit: Arc::clone(&self.connection_permit),
190            post_timeout: self.post_timeout,
191            task_handle: Arc::clone(&self.task_handle),
192            connect_lock: Arc::clone(&self.connect_lock),
193            account_id: self.account_id,
194            transport_backend: self.transport_backend,
195            proxy_url: self.proxy_url.clone(),
196            socket_sink: self.socket_sink.clone(),
197            socket_control: self.socket_control.clone(),
198        }
199    }
200}
201
202impl HyperliquidWebSocketClient {
203    /// Creates a new Hyperliquid WebSocket client without connecting.
204    ///
205    /// If `url` is `None`, the appropriate URL will be determined from the `environment`:
206    /// - `Mainnet`: `wss://api.hyperliquid.xyz/ws`
207    /// - `Testnet`: `wss://api.hyperliquid-testnet.xyz/ws`
208    ///
209    /// The connection will be established when `connect()` is called.
210    pub fn new(
211        url: Option<String>,
212        environment: HyperliquidEnvironment,
213        account_id: Option<AccountId>,
214        transport_backend: TransportBackend,
215        proxy_url: Option<String>,
216    ) -> Self {
217        let url = url.unwrap_or_else(|| ws_url(environment).to_string());
218        let rate_limits = shared_websocket_limits(environment, &url, proxy_url.as_deref());
219        let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
220            ConnectionMode::Closed as u8,
221        ))));
222        let (cmd_tx, _) = tokio::sync::mpsc::unbounded_channel();
223
224        Self {
225            url: url.into(),
226            connection_mode,
227            signal: Arc::new(AtomicBool::new(false)),
228            auth_tracker: AuthTracker::new(),
229            subscriptions: SubscriptionState::new(':'),
230            book_streams: BookStreamRegistry::default(),
231            trade_streams: TradeStreamRegistry::default(),
232            trade_stream_lock: Arc::new(Mutex::new(())),
233            quote_streams: Arc::new(DashMap::new()),
234            instruments: Arc::new(AtomicMap::new()),
235            cache_tx: Arc::new(Mutex::new(cmd_tx.clone())),
236            bar_types: Arc::new(AtomicMap::new()),
237            asset_context_subs: Arc::new(DashMap::new()),
238            all_dex_asset_ctxs_instrument_ids: Arc::new(AtomicMap::new()),
239            cloid_cache: Arc::new(Mutex::new(FifoCacheMap::new())),
240            post_router: PostRouter::with_inflight(Arc::clone(&rate_limits.post_slots)),
241            post_ids: Arc::new(PostIds::new(1)),
242            rate_limits,
243            client_id: NEXT_WEBSOCKET_CLIENT_ID.fetch_add(1, Ordering::Relaxed),
244            connection_permit: Arc::new(Mutex::new(None)),
245            post_timeout: HTTP_TIMEOUT,
246            cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
247            out_rx: None,
248            task_handle: Arc::new(SharedTaskSlot::new()),
249            connect_lock: Arc::new(tokio::sync::Mutex::new(())),
250            account_id,
251            transport_backend,
252            proxy_url: proxy_url.map(SecretString::from),
253            socket_sink: None,
254            socket_control: None,
255        }
256    }
257
258    /// Configures socket state reporting for the underlying transport.
259    #[must_use]
260    pub fn with_state_sink(mut self, state_sink: SocketStateSink) -> Self {
261        self.socket_sink = Some(state_sink);
262        self
263    }
264
265    /// Configures state reporting and reconnect control for the underlying transport.
266    #[must_use]
267    pub(crate) fn with_socket_control(mut self, control: SocketControl) -> Self {
268        self.socket_control = Some(control);
269        self
270    }
271
272    /// Establishes the WebSocket connection and waits for the message handler to initialize.
273    pub async fn connect(&mut self) -> anyhow::Result<()> {
274        let connect_lock = Arc::clone(&self.connect_lock);
275        let _guard = connect_lock.lock().await;
276        self.connect_locked().await
277    }
278
279    async fn connect_locked(&mut self) -> anyhow::Result<()> {
280        if self.is_active() {
281            log::warn!("WebSocket already connected");
282            return Ok(());
283        }
284
285        if !self.task_handle.is_empty() {
286            self.disconnect_locked().await?;
287        }
288
289        if self.connection_permit.lock().is_none() {
290            let permit = Arc::clone(&self.rate_limits.connection_slots)
291                .try_acquire_owned()
292                .map_err(|_| {
293                    anyhow::anyhow!(
294                        "Hyperliquid allows at most {} WebSocket connections per route",
295                        crate::common::consts::HYPERLIQUID_WS_CONNECTIONS_MAX,
296                    )
297                })?;
298            *self.connection_permit.lock() = Some(permit);
299        }
300
301        // A fresh socket has no venue-side subscriptions; stale book stream
302        // entries must not gate the venue subscribe for re-subscriptions
303        self.book_streams.clear();
304
305        let (message_handler, raw_rx) = channel_message_handler();
306        let headers = create_standard_nautilus_headers();
307
308        let cfg = WebSocketConfig {
309            url: self.url.expose().to_owned(),
310            headers,
311            heartbeat_interval_secs: None,
312            heartbeat_payload: None,
313            connect_timeout_ms: Some(15_000),
314            reconnect_delay_initial_ms: Some(250),
315            reconnect_delay_max_ms: Some(5_000),
316            reconnect_backoff_factor: Some(2.0),
317            reconnect_jitter_ms: Some(200),
318            reconnect_max_attempts: None,
319            heartbeat_timeout_secs: Some(HEARTBEAT_INTERVAL.as_secs() * 3),
320            idle_timeout_ms: None,
321            writer_capacity: None,
322            backend: self.transport_backend,
323            proxy_url: self
324                .proxy_url
325                .as_ref()
326                .map(|value| value.expose_secret().to_owned()),
327            max_message_size_bytes: None,
328            max_frame_size_bytes: None,
329        };
330        let connection_rate_keys: Arc<[Ustr]> = Arc::from([self.rate_limits.connection_key()]);
331        let client_result = WebSocketClient::builder()
332            .config(cfg)
333            .message_handler(message_handler)
334            .rate_limiter(Arc::clone(&self.rate_limits.messages))
335            .connection_rate_limiter(Arc::clone(&self.rate_limits.connections))
336            .connection_rate_keys(connection_rate_keys)
337            .maybe_state_sink(
338                self.socket_control
339                    .as_ref()
340                    .map(SocketControl::sink)
341                    .or_else(|| self.socket_sink.clone()),
342            )
343            .connect()
344            .await;
345        let client = match client_result {
346            Ok(client) => client,
347            Err(e) => {
348                self.connection_permit.lock().take();
349                return Err(e.into());
350            }
351        };
352
353        if let Some(control) = &self.socket_control {
354            let handle = client.reconnect_handle();
355            control.register(move || handle.request_reconnect());
356        }
357
358        // Create channels for handler communication
359        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
360        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
361
362        self.out_rx = Some(out_rx);
363        let connection_mode = client.connection_mode_atomic();
364        let (initialized_tx, initialized_rx) = tokio::sync::oneshot::channel();
365
366        if let Err(e) = cmd_tx.send(HandlerCommand::SetClient {
367            client,
368            initialized: initialized_tx,
369        }) {
370            self.release_limit_reservations();
371            anyhow::bail!("Failed to send SetClient command: {e}");
372        }
373
374        {
375            let mut command_sender = self.cmd_tx.write().await;
376            // Synchronous cache updates share the snapshot and sender handoff
377            let mut cache_sender = self.cache_tx.lock();
378            // Initialize handler with existing instruments
379            let instruments_vec: Vec<InstrumentAny> =
380                self.instruments.load().values().cloned().collect();
381
382            if !instruments_vec.is_empty()
383                && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments_vec))
384            {
385                log::error!("Failed to send InitializeInstruments: {e}");
386            }
387
388            for (coin, uses) in self.trade_streams.snapshot() {
389                if let Err(e) = cmd_tx.send(HandlerCommand::UpdateTradeSubs { coin, uses }) {
390                    log::error!("Failed to send UpdateTradeSubs: {e}");
391                }
392            }
393
394            let all_dex_asset_ctxs_instrument_ids = self
395                .all_dex_asset_ctxs_instrument_ids
396                .load()
397                .iter()
398                .map(|(dex, instrument_ids)| (*dex, instrument_ids.clone()))
399                .collect();
400
401            if let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(
402                all_dex_asset_ctxs_instrument_ids,
403            )) {
404                log::error!("Failed to send CacheAllDexAssetCtxsInstrumentIds: {e}");
405            }
406
407            *cache_sender = cmd_tx.clone();
408            *command_sender = cmd_tx.clone();
409        }
410
411        // Spawn handler task
412        let signal = Arc::clone(&self.signal);
413        let account_id = self.account_id;
414        let subscriptions = self.subscriptions.clone();
415        let book_streams = self.book_streams.clone();
416        let cmd_tx_for_reconnect = cmd_tx.clone();
417        let cloid_cache = Arc::clone(&self.cloid_cache);
418        let post_router = Arc::clone(&self.post_router);
419        let rate_limits = Arc::clone(&self.rate_limits);
420        let client_id = self.client_id;
421        let connection_permit = Arc::clone(&self.connection_permit);
422
423        if let Err(e) = self.task_handle.spawn(async move {
424            let mut handler = FeedHandler::new(
425                signal,
426                cmd_rx,
427                raw_rx,
428                out_tx,
429                account_id,
430                subscriptions.clone(),
431                cloid_cache,
432                post_router,
433                Arc::clone(&rate_limits),
434                client_id,
435            );
436
437            let resubscribe_all = || {
438                let topics = subscriptions.all_topics();
439                if topics.is_empty() {
440                    log::debug!("No active subscriptions to restore after reconnection");
441                    return;
442                }
443
444                log::info!(
445                    "Resubscribing to {} active subscriptions after reconnection",
446                    topics.len()
447                );
448
449                for topic in topics {
450                    match subscription_from_topic(&topic) {
451                        Ok(mut subscription) => {
452                            // Topic text cannot carry l2Book precision options;
453                            // replay the shape the stream was opened with
454                            if let SubscriptionRequest::L2Book {
455                                coin,
456                                n_sig_figs,
457                                mantissa,
458                            } = &mut subscription
459                                && let Some(options) = book_streams.options(coin)
460                            {
461                                *n_sig_figs = options.n_sig_figs;
462                                *mantissa = options.mantissa;
463                            }
464
465                            if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
466                                subscriptions: vec![subscription],
467                            }) {
468                                log::error!("Failed to send resubscribe command: {e}");
469                            }
470                        }
471                        Err(e) => {
472                            log::error!(
473                                "Failed to reconstruct subscription from topic: topic={topic}, {e}"
474                            );
475                        }
476                    }
477                }
478            };
479
480            loop {
481                match handler.next().await {
482                    Some(NautilusWsMessage::Reconnected) => {
483                        log::info!("WebSocket reconnected");
484                        let pending_unsubscribe = subscriptions.pending_unsubscribe_topics();
485                        rate_limits.release_subscriptions(
486                            client_id,
487                            pending_unsubscribe.iter().map(String::as_str),
488                        );
489                        subscriptions.reset_after_reconnect();
490                        resubscribe_all();
491
492                        if handler.send(NautilusWsMessage::Reconnected).is_err() {
493                            if handler.is_stopped() {
494                                log::debug!("Failed to send reconnect event (receiver dropped)");
495                            } else {
496                                log::error!("Failed to send reconnect event (receiver dropped)");
497                            }
498                            break;
499                        }
500                    }
501                    Some(msg) => {
502                        if handler.send(msg).is_err() {
503                            if handler.is_stopped() {
504                                log::debug!("Failed to send message (receiver dropped)");
505                            } else {
506                                log::error!("Failed to send message (receiver dropped)");
507                            }
508                            break;
509                        }
510                    }
511                    None => {
512                        if handler.is_stopped() {
513                            log::debug!("Stop signal received, ending message processing");
514                            break;
515                        }
516                        log::warn!("WebSocket stream ended unexpectedly");
517                        break;
518                    }
519                }
520            }
521            rate_limits.release_client(client_id);
522            connection_permit.lock().take();
523            log::debug!("Handler task completed");
524        }) {
525            self.out_rx = None;
526            self.release_limit_reservations();
527            anyhow::bail!("Failed to start Hyperliquid WebSocket handler task: {e}");
528        }
529
530        self.wait_for_initialization(initialized_rx, connection_mode)
531            .await?;
532
533        log::debug!("Hyperliquid WebSocket connected: {}", self.url.origin());
534
535        Ok(())
536    }
537
538    async fn wait_for_initialization(
539        &mut self,
540        initialized: tokio::sync::oneshot::Receiver<()>,
541        connection_mode: Arc<AtomicU8>,
542    ) -> anyhow::Result<()> {
543        let setup_guard = TaskGroupGuard::new(&[], || {
544            self.task_handle.abort();
545            self.release_limit_reservations();
546        });
547
548        let result = tokio::time::timeout(HTTP_TIMEOUT, initialized)
549            .await
550            .context("Hyperliquid WebSocket handler initialization timed out")
551            .and_then(|result| {
552                result.context("Hyperliquid WebSocket handler stopped before initialization")
553            });
554
555        if result.is_ok() {
556            self.connection_mode.store(connection_mode);
557            setup_guard.disarm();
558            return Ok(());
559        }
560
561        drop(setup_guard);
562        self.out_rx = None;
563        self.disconnect_locked().await?;
564        result
565    }
566
567    pub fn set_post_timeout(&mut self, timeout: Duration) {
568        self.post_timeout = timeout;
569    }
570
571    pub(crate) fn begin_shutdown(&self) {
572        self.signal.store(true, Ordering::Relaxed);
573    }
574
575    /// Replaces state owned by a terminated WebSocket generation.
576    ///
577    /// This must run only after the handler task has stopped. Replacing the
578    /// shared containers, rather than clearing them, prevents old clones or
579    /// in-flight work from mutating a subsequent connection generation.
580    pub(crate) fn reset_runtime_state(&mut self) {
581        self.release_limit_reservations();
582        self.subscriptions = SubscriptionState::new(':');
583        self.book_streams = BookStreamRegistry::default();
584        self.trade_streams = TradeStreamRegistry::default();
585        self.trade_stream_lock = Arc::new(Mutex::new(()));
586        self.quote_streams = Arc::new(DashMap::new());
587        self.instruments = Arc::new(AtomicMap::new());
588        let (cache_tx, _) = tokio::sync::mpsc::unbounded_channel();
589        self.cache_tx = Arc::new(Mutex::new(cache_tx));
590        self.bar_types = Arc::new(AtomicMap::new());
591        self.asset_context_subs = Arc::new(DashMap::new());
592        self.all_dex_asset_ctxs_instrument_ids = Arc::new(AtomicMap::new());
593        self.cloid_cache = Arc::new(Mutex::new(FifoCacheMap::new()));
594        self.out_rx = None;
595
596        if let Some(control) = &self.socket_control {
597            control.deregister();
598        }
599        self.connection_mode
600            .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
601        self.signal.store(false, Ordering::Relaxed);
602    }
603
604    /// Disconnects the WebSocket connection.
605    pub async fn disconnect(&mut self) -> anyhow::Result<()> {
606        let connect_lock = Arc::clone(&self.connect_lock);
607        let _guard = connect_lock.lock().await;
608        self.disconnect_locked().await
609    }
610
611    async fn disconnect_locked(&self) -> anyhow::Result<()> {
612        log::debug!("Disconnecting Hyperliquid WebSocket");
613
614        if let Some(control) = &self.socket_control {
615            control.deregister();
616        }
617        self.signal.store(true, Ordering::Relaxed);
618
619        if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
620            log::debug!(
621                "Failed to send disconnect command (handler may already be shut down): {e}"
622            );
623        }
624
625        if self.task_handle.is_empty() {
626            log::debug!("No task handle to await");
627        } else {
628            log::debug!("Waiting for task handle to complete");
629
630            if let Some(outcome) = self
631                .task_handle
632                .finish(Duration::from_secs(2), Duration::from_secs(2))
633                .await
634            {
635                match outcome {
636                    TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
637                    TaskJoinOutcome::Failed(error) => {
638                        self.release_limit_reservations();
639                        anyhow::bail!("Hyperliquid WebSocket handler failed: {error}");
640                    }
641                    TaskJoinOutcome::Incomplete => {
642                        self.release_limit_reservations();
643                        anyhow::bail!("Hyperliquid WebSocket handler did not stop after abort");
644                    }
645                }
646            }
647        }
648        self.release_limit_reservations();
649        log::debug!("Disconnected");
650        Ok(())
651    }
652
653    /// Requests a full transport reconnect.
654    ///
655    /// Transitions the connection from `Active` to `Reconnect`; the network
656    /// layer re-establishes the socket with backoff and the handler replays all
657    /// active subscriptions once reconnected. Returns `false` when the
658    /// connection is not active (already reconnecting, disconnecting, or
659    /// closed), leaving any in-flight transition untouched.
660    pub fn request_reconnect(&self) -> bool {
661        ConnectionMode::request_reconnect(&self.connection_mode.load())
662    }
663
664    /// Send a typed exchange action through the Hyperliquid WebSocket post API.
665    ///
666    /// The supplied HTTP client is used only as the canonical signer for the
667    /// action envelope. The signed payload is sent over the active WebSocket
668    /// connection and the response is correlated by post id.
669    pub async fn post_action_exec(
670        &self,
671        signer: &HyperliquidHttpClient,
672        action: &HyperliquidExchangeAction,
673    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
674        self.post_action_exec_with_timeout(signer, action, self.post_timeout, None)
675            .await
676    }
677
678    /// Send a typed exchange action with a caller-specified timeout and optional expiry.
679    pub async fn post_action_exec_with_timeout(
680        &self,
681        signer: &HyperliquidHttpClient,
682        action: &HyperliquidExchangeAction,
683        timeout: Duration,
684        expires_after: Option<u64>,
685    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
686        self.post_action_result(signer, action, timeout, expires_after)
687            .await
688            .map_err(PostRequestError::into_error)
689    }
690
691    pub(crate) async fn post_action_command(
692        &self,
693        signer: &HyperliquidHttpClient,
694        action: &HyperliquidExchangeAction,
695    ) -> Result<HyperliquidExchangeResponse, PostRequestError> {
696        self.post_action_result(signer, action, self.post_timeout, None)
697            .await
698    }
699
700    async fn post_action_result(
701        &self,
702        signer: &HyperliquidHttpClient,
703        action: &HyperliquidExchangeAction,
704        timeout: Duration,
705        expires_after: Option<u64>,
706    ) -> Result<HyperliquidExchangeResponse, PostRequestError> {
707        let weight = exec_action_weight(action);
708        let payload = signer
709            .sign_action_exec_request(action, expires_after)
710            .map_err(PostRequestError::BeforeDispatch)?;
711        let response = self
712            .send_post_request_result(PostRequest::Action { payload }, timeout)
713            .await?;
714
715        match response.response {
716            PostResponsePayload::Action { payload } => {
717                let parsed = self
718                    .parse_post_action_payload(&payload)
719                    .map_err(PostRequestError::AfterDispatch)?;
720
721                self.check_post_action_response(parsed)
722            }
723            PostResponsePayload::Error { payload } => Err(PostRequestError::AfterDispatch(
724                map_post_payload_error(payload, weight),
725            )),
726            PostResponsePayload::Info { payload } => Err(PostRequestError::AfterDispatch(
727                HyperliquidError::decode(self.post_decode_context(&payload, "unexpected_info")),
728            )),
729        }
730    }
731
732    /// Submit an order through the Hyperliquid WebSocket post API.
733    ///
734    /// The HTTP client supplies signing credentials, builder attribution, and
735    /// cached instrument metadata. The action itself is sent over WebSocket.
736    ///
737    /// Returns an [`OrderStatusReport`] describing the venue's immediate
738    /// response (`Filled` for an atomic IOC fill, `Accepted` for a resting
739    /// order), or `None` when the venue deferred the order without an oid (for
740    /// example a `waitingForFill` trigger child): the order stays `SUBMITTED`
741    /// until the user-events stream delivers the first `OrderAccepted`.
742    #[allow(
743        clippy::too_many_arguments,
744        reason = "matches the Python and HTTP order submit surface"
745    )]
746    pub async fn submit_order(
747        &self,
748        signer: &HyperliquidHttpClient,
749        instrument_id: InstrumentId,
750        client_order_id: ClientOrderId,
751        order_side: OrderSide,
752        order_type: OrderType,
753        quantity: Quantity,
754        time_in_force: TimeInForce,
755        price: Option<Price>,
756        trigger_price: Option<Price>,
757        post_only: bool,
758        reduce_only: bool,
759    ) -> HyperliquidResult<Option<OrderStatusReport>> {
760        let symbol = instrument_id.symbol.inner();
761        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
762            HyperliquidError::bad_request(format!(
763                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
764            ))
765        })?;
766        let is_buy = matches!(order_side, OrderSide::Buy);
767        let price_precision = signer.get_price_precision_for_symbol(symbol);
768
769        let price_decimal = match price {
770            Some(px) => normalize_or_validate_wire_price(
771                px.as_decimal(),
772                "Price",
773                price_precision,
774                signer.normalize_prices(),
775            )
776            .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?,
777            None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
778            None if matches!(
779                order_type,
780                OrderType::StopMarket | OrderType::MarketIfTouched
781            ) =>
782            {
783                match trigger_price {
784                    Some(tp) => {
785                        let derived = derive_limit_from_trigger(
786                            tp.as_decimal().normalize(),
787                            is_buy,
788                            signer.market_order_slippage_bps(),
789                        );
790                        let sig_rounded = round_to_sig_figs(derived, 5);
791                        clamp_price_to_precision(sig_rounded, price_precision.unwrap_or(2), is_buy)
792                            .normalize()
793                    }
794                    None => Decimal::ZERO,
795                }
796            }
797            None => {
798                return Err(HyperliquidError::bad_request(
799                    "Limit orders require a price",
800                ));
801            }
802        };
803
804        let size_decimal = quantity.as_decimal().normalize();
805        let kind = hyperliquid_order_kind(
806            order_type,
807            time_in_force,
808            post_only,
809            trigger_price,
810            signer.normalize_prices(),
811            price_precision,
812        )?;
813
814        let order = HyperliquidExchangePlaceOrderRequest {
815            asset,
816            is_buy,
817            price: price_decimal,
818            size: size_decimal,
819            reduce_only,
820            kind,
821            cloid: Some(signer.get_or_generate_client_order_id_cloid(client_order_id)),
822        };
823
824        if let Some(cloid) = order.cloid {
825            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
826        }
827        let action = HyperliquidExchangeAction::Order {
828            orders: vec![order],
829            grouping: HyperliquidExchangeGrouping::Na,
830            builder: signer.builder_attribution(),
831        };
832        let response = self.post_action_exec(signer, &action).await?;
833
834        // Verdict first: a real rejection must still error
835        ensure_ws_action_accepted(&response, "Order submission")?;
836
837        let payload_bytes = Self::response_bytes(&response);
838
839        // Past the verdict, a build failure is local; defer to WS, never reject
840        let report = signer.build_submit_order_report(
841            instrument_id,
842            client_order_id,
843            order_side,
844            order_type,
845            quantity,
846            time_in_force,
847            price,
848            trigger_price,
849            response,
850        );
851        Ok(self.resolve_submit_report(report, payload_bytes))
852    }
853
854    /// Submit multiple orders through the Hyperliquid WebSocket post API.
855    ///
856    /// Returns one [`OrderStatusReport`] per accepted order in submission
857    /// order. Deferred trigger children of a `normalTpsl` bracket are absent
858    /// from the result; they stay `SUBMITTED` until the user-events stream
859    /// delivers an `OrderAccepted` with the real oid.
860    ///
861    /// # Errors
862    ///
863    /// Returns an error if any quantity is quote-denominated: this raw path has no cached market
864    /// data for a quote-to-base conversion, so such orders must be submitted through the execution
865    /// client instead.
866    pub async fn submit_orders(
867        &self,
868        signer: &HyperliquidHttpClient,
869        orders: &[&OrderAny],
870    ) -> HyperliquidResult<Vec<OrderStatusReport>> {
871        let mut hyperliquid_orders = Vec::with_capacity(orders.len());
872        let mut client_order_ids = Vec::with_capacity(orders.len());
873
874        for order in orders {
875            if order.is_quote_quantity() {
876                return Err(HyperliquidError::bad_request(format!(
877                    "Quote-denominated quantity order {} must submit through the execution \
878                     client for quote-to-base conversion",
879                    order.client_order_id()
880                )));
881            }
882
883            let instrument_id = order.instrument_id();
884            let symbol = instrument_id.symbol.inner();
885            let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
886                HyperliquidError::bad_request(format!(
887                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
888                ))
889            })?;
890            let price_decimals = signer.get_price_precision_for_symbol(symbol);
891            let request = order_to_hyperliquid_request_with_optional_decimals(
892                order,
893                asset,
894                price_decimals,
895                signer.normalize_prices(),
896                signer.market_order_slippage_bps(),
897                None,
898            )
899            .map_err(|e| HyperliquidError::bad_request(format!("Failed to convert order: {e}")))?;
900            client_order_ids.push(order.client_order_id());
901            hyperliquid_orders.push(request);
902        }
903
904        for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
905            let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
906            request.cloid = Some(cloid);
907            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
908        }
909
910        let grouping =
911            determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
912        let action = HyperliquidExchangeAction::Order {
913            orders: hyperliquid_orders,
914            grouping,
915            builder: signer.builder_attribution(),
916        };
917        let response = self.post_action_exec(signer, &action).await?;
918
919        ensure_ws_action_accepted(&response, "Order list submission")?;
920
921        let payload_bytes = Self::response_bytes(&response);
922
923        // Past the verdict, a build failure is local; defer to WS, never reject
924        let reports = signer.build_submit_orders_reports(orders, grouping, response);
925        Ok(self.resolve_submit_report(reports, payload_bytes))
926    }
927
928    /// Cancel an order through the Hyperliquid WebSocket post API.
929    pub async fn cancel_order(
930        &self,
931        signer: &HyperliquidHttpClient,
932        instrument_id: InstrumentId,
933        client_order_id: Option<ClientOrderId>,
934        venue_order_id: Option<VenueOrderId>,
935    ) -> HyperliquidResult<()> {
936        let symbol = instrument_id.symbol.inner();
937        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
938            HyperliquidError::bad_request(format!(
939                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
940            ))
941        })?;
942        let action = if let Some(client_order_id) = client_order_id {
943            if let Some(cloid) = signer.cached_client_order_id_cloid(&client_order_id) {
944                HyperliquidExchangeAction::CancelByCloid {
945                    cancels: vec![HyperliquidExchangeCancelByCloidRequest { asset, cloid }],
946                    fast: None,
947                }
948            } else if let Some(oid) = venue_order_id {
949                let oid = oid
950                    .as_str()
951                    .parse::<u64>()
952                    .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
953                HyperliquidExchangeAction::Cancel {
954                    cancels: vec![HyperliquidExchangeCancelOrderRequest { asset, oid }],
955                    fast: None,
956                }
957            } else {
958                let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
959                HyperliquidExchangeAction::CancelByCloid {
960                    cancels: vec![HyperliquidExchangeCancelByCloidRequest { asset, cloid }],
961                    fast: None,
962                }
963            }
964        } else if let Some(oid) = venue_order_id {
965            let oid = oid
966                .as_str()
967                .parse::<u64>()
968                .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
969            HyperliquidExchangeAction::Cancel {
970                cancels: vec![HyperliquidExchangeCancelOrderRequest { asset, oid }],
971                fast: None,
972            }
973        } else {
974            return Err(HyperliquidError::bad_request(
975                "Either client_order_id or venue_order_id must be provided",
976            ));
977        };
978        let response = self.post_action_exec(signer, &action).await?;
979
980        ensure_ws_action_accepted(&response, "Cancel order")
981    }
982
983    /// Cancel multiple orders through one Hyperliquid WebSocket post action.
984    pub async fn cancel_orders(
985        &self,
986        signer: &HyperliquidHttpClient,
987        cancels: &[(InstrumentId, ClientOrderId, Option<VenueOrderId>)],
988    ) -> HyperliquidResult<Vec<Option<String>>> {
989        let mut cloid_requests = Vec::new();
990        let mut cloid_indices = Vec::new();
991        let mut oid_requests = Vec::new();
992        let mut oid_indices = Vec::new();
993        let mut results = vec![None; cancels.len()];
994
995        for (index, (instrument_id, client_order_id, venue_order_id)) in cancels.iter().enumerate()
996        {
997            let symbol = instrument_id.symbol.inner();
998            let Some(asset) = signer.get_asset_index_for_symbol(symbol) else {
999                results[index] = Some(format!(
1000                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1001                ));
1002                continue;
1003            };
1004
1005            if let Some(cloid) = signer.cached_client_order_id_cloid(client_order_id) {
1006                cloid_requests.push(HyperliquidExchangeCancelByCloidRequest { asset, cloid });
1007                cloid_indices.push(index);
1008            } else if let Some(venue_order_id) = venue_order_id {
1009                match venue_order_id.as_str().parse::<u64>() {
1010                    Ok(oid) => {
1011                        oid_requests.push(HyperliquidExchangeCancelOrderRequest { asset, oid });
1012                        oid_indices.push(index);
1013                    }
1014                    Err(_) => {
1015                        results[index] = Some("Invalid venue order ID format".to_string());
1016                    }
1017                }
1018            } else {
1019                let cloid = signer.get_or_generate_client_order_id_cloid(*client_order_id);
1020                cloid_requests.push(HyperliquidExchangeCancelByCloidRequest { asset, cloid });
1021                cloid_indices.push(index);
1022            }
1023        }
1024
1025        if cloid_requests.is_empty() && oid_requests.is_empty() {
1026            return Ok(results);
1027        }
1028
1029        if !cloid_requests.is_empty() {
1030            let action = HyperliquidExchangeAction::CancelByCloid {
1031                cancels: cloid_requests,
1032                fast: None,
1033            };
1034            let errors = self
1035                .post_cancel_action_errors(signer, &action, cloid_indices.len())
1036                .await?;
1037
1038            for (index, error) in cloid_indices.into_iter().zip(errors) {
1039                results[index] = error;
1040            }
1041        }
1042
1043        if !oid_requests.is_empty() {
1044            let action = HyperliquidExchangeAction::Cancel {
1045                cancels: oid_requests,
1046                fast: None,
1047            };
1048            let errors = self
1049                .post_cancel_action_errors(signer, &action, oid_indices.len())
1050                .await?;
1051
1052            for (index, error) in oid_indices.into_iter().zip(errors) {
1053                results[index] = error;
1054            }
1055        }
1056
1057        Ok(results)
1058    }
1059
1060    async fn post_cancel_action_errors(
1061        &self,
1062        signer: &HyperliquidHttpClient,
1063        action: &HyperliquidExchangeAction,
1064        request_count: usize,
1065    ) -> HyperliquidResult<Vec<Option<String>>> {
1066        match self.post_cancel_action(signer, action).await {
1067            Ok(response) if response.is_ok() => {
1068                match cancel_errors_for_requests(extract_inner_errors(&response), request_count) {
1069                    Ok(errors) => Ok(errors),
1070                    Err(e) => Ok(vec![Some(e.to_string()); request_count]),
1071                }
1072            }
1073            Ok(response) => Ok(self.cancel_response_errors(&response, request_count)),
1074            Err(e) => Err(e),
1075        }
1076    }
1077
1078    async fn post_cancel_action(
1079        &self,
1080        signer: &HyperliquidHttpClient,
1081        action: &HyperliquidExchangeAction,
1082    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
1083        let weight = exec_action_weight(action);
1084
1085        let payload = signer.sign_action_exec_request(action, None)?;
1086        let response = self
1087            .send_post_request(PostRequest::Action { payload }, self.post_timeout)
1088            .await?;
1089
1090        match response.response {
1091            PostResponsePayload::Action { payload } => self.parse_post_action_payload(&payload),
1092            PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
1093            PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(
1094                self.post_decode_context(&payload, "unexpected_info"),
1095            )),
1096        }
1097    }
1098
1099    fn response_bytes(response: &HyperliquidExchangeResponse) -> usize {
1100        match response {
1101            HyperliquidExchangeResponse::Status { response, .. } => response.to_string().len(),
1102            HyperliquidExchangeResponse::Error { error } => error.len(),
1103        }
1104    }
1105
1106    fn resolve_submit_report<T: Default>(
1107        &self,
1108        result: HyperliquidResult<T>,
1109        payload_bytes: usize,
1110    ) -> T {
1111        match result {
1112            Ok(report) => report,
1113            Err(_) => {
1114                log::warn!(
1115                    "Failed to build WebSocket submit report: class=order_status, client_id={}, payload_bytes={payload_bytes}, category=conversion; awaiting WS reconciliation",
1116                    self.client_id,
1117                );
1118                T::default()
1119            }
1120        }
1121    }
1122
1123    fn parse_post_action_payload(
1124        &self,
1125        payload: &serde_json::Value,
1126    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
1127        HyperliquidExchangeResponse::deserialize(payload).map_err(|e| {
1128            let category = format!("{:?}", e.classify());
1129            let context = self.post_decode_context(payload, &category);
1130            HyperliquidError::Serde(<serde_json::Error as serde::de::Error>::custom(context))
1131        })
1132    }
1133
1134    fn check_post_action_response(
1135        &self,
1136        parsed: HyperliquidExchangeResponse,
1137    ) -> Result<HyperliquidExchangeResponse, PostRequestError> {
1138        self.check_post_status(&parsed)
1139            .map_err(PostRequestError::AfterDispatch)?;
1140
1141        match &parsed {
1142            HyperliquidExchangeResponse::Status { status, response }
1143                if status == RESPONSE_STATUS_ERR =>
1144            {
1145                let reason = response
1146                    .as_str()
1147                    .map_or_else(|| response.to_string(), str::to_string);
1148                let error = HyperliquidError::bad_request(format!("API error: {reason}"));
1149                Err(PostRequestError::Rejected {
1150                    error,
1151                    reason: extract_error_message(&parsed),
1152                })
1153            }
1154            HyperliquidExchangeResponse::Error { error } => Err(PostRequestError::Rejected {
1155                error: HyperliquidError::bad_request(format!("API error: {error}")),
1156                reason: error.clone(),
1157            }),
1158            _ => Ok(parsed),
1159        }
1160    }
1161
1162    fn cancel_response_errors(
1163        &self,
1164        response: &HyperliquidExchangeResponse,
1165        request_count: usize,
1166    ) -> Vec<Option<String>> {
1167        let reason = self
1168            .check_post_status(response)
1169            .err()
1170            .map_or_else(|| extract_error_message(response), |e| e.to_string());
1171        let reason = format!("Cancel orders failed: {reason}");
1172        vec![Some(reason); request_count]
1173    }
1174
1175    fn check_post_status(&self, parsed: &HyperliquidExchangeResponse) -> HyperliquidResult<()> {
1176        if let HyperliquidExchangeResponse::Status { status, response } = parsed
1177            && status != RESPONSE_STATUS_OK
1178            && status != RESPONSE_STATUS_ERR
1179        {
1180            let context = self.post_decode_context(response, "unexpected_status");
1181            return Err(HyperliquidError::bad_request(context));
1182        }
1183
1184        Ok(())
1185    }
1186
1187    fn post_decode_context(&self, payload: &serde_json::Value, category: &str) -> String {
1188        format!(
1189            "Invalid WebSocket post response: class=action, client_id={}, payload_bytes={}, category={category}",
1190            self.client_id,
1191            payload.to_string().len(),
1192        )
1193    }
1194
1195    /// Modify an order through the Hyperliquid WebSocket post API.
1196    #[allow(
1197        clippy::too_many_arguments,
1198        reason = "matches the Python and HTTP order modify surface"
1199    )]
1200    pub async fn modify_order(
1201        &self,
1202        signer: &HyperliquidHttpClient,
1203        instrument_id: InstrumentId,
1204        venue_order_id: Option<VenueOrderId>,
1205        order_side: OrderSide,
1206        order_type: OrderType,
1207        price: Price,
1208        quantity: Quantity,
1209        trigger_price: Option<Price>,
1210        reduce_only: bool,
1211        post_only: bool,
1212        time_in_force: TimeInForce,
1213        client_order_id: Option<ClientOrderId>,
1214    ) -> HyperliquidResult<()> {
1215        let symbol = instrument_id.symbol.inner();
1216        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1217            HyperliquidError::bad_request(format!(
1218                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1219            ))
1220        })?;
1221        let oid = match client_order_id
1222            .as_ref()
1223            .and_then(|id| signer.unique_cached_client_order_id_cloid(id))
1224        {
1225            Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
1226            None => {
1227                let Some(venue_order_id) = venue_order_id.as_ref() else {
1228                    return Err(HyperliquidError::bad_request(
1229                        "venue_order_id or unique cached CLOID is required for modify",
1230                    ));
1231                };
1232                HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
1233                    .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?
1234            }
1235        };
1236        let is_buy = matches!(order_side, OrderSide::Buy);
1237        let price_decimals = signer.get_price_precision_for_symbol(symbol);
1238        let price = normalize_or_validate_wire_price(
1239            price.as_decimal(),
1240            "Price",
1241            price_decimals,
1242            signer.normalize_prices(),
1243        )
1244        .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
1245        let kind = hyperliquid_order_kind(
1246            order_type,
1247            time_in_force,
1248            post_only,
1249            trigger_price,
1250            signer.normalize_prices(),
1251            price_decimals,
1252        )?;
1253        let cloid =
1254            client_order_id.map(|id| (id, signer.get_or_generate_client_order_id_cloid(id)));
1255        let order = HyperliquidExchangePlaceOrderRequest {
1256            asset,
1257            is_buy,
1258            price,
1259            size: quantity.as_decimal().normalize(),
1260            reduce_only,
1261            kind,
1262            cloid: cloid.map(|(_, cloid)| cloid),
1263        };
1264
1265        if let Some((client_order_id, cloid)) = cloid {
1266            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
1267        }
1268        let action = HyperliquidExchangeAction::Modify {
1269            modify: HyperliquidExchangeModifyOrderRequest { oid, order },
1270        };
1271        let response = self.post_action_exec(signer, &action).await?;
1272
1273        ensure_ws_action_accepted(&response, "Modify order")
1274    }
1275
1276    async fn send_post_request(
1277        &self,
1278        request: PostRequest,
1279        timeout: Duration,
1280    ) -> HyperliquidResult<PostResponse> {
1281        self.send_post_request_result(request, timeout)
1282            .await
1283            .map_err(PostRequestError::into_error)
1284    }
1285
1286    async fn send_post_request_result(
1287        &self,
1288        request: PostRequest,
1289        timeout: Duration,
1290    ) -> Result<PostResponse, PostRequestError> {
1291        let id = self.post_ids.next();
1292        let Some(deadline) = tokio::time::Instant::now().checked_add(timeout) else {
1293            return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1294        };
1295
1296        let cancellation_token = CancellationToken::new();
1297        let rx = tokio::select! {
1298            biased;
1299            () = tokio::time::sleep_until(deadline) => return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout)),
1300            result = self.post_router.register_with_cancellation(id, &cancellation_token) => result.map_err(PostRequestError::BeforeDispatch)?,
1301        };
1302
1303        let _cancellation_guard = cancellation_token.drop_guard_ref();
1304
1305        let send_result = {
1306            let cmd_tx = tokio::select! {
1307                biased;
1308                () = tokio::time::sleep_until(deadline) => {
1309                    self.cancel_post_registration(id, &cancellation_token).await;
1310                    return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1311                }
1312                cmd_tx = self.cmd_tx.read() => cmd_tx,
1313            };
1314
1315            if cancellation_token.is_cancelled() || tokio::time::Instant::now() >= deadline {
1316                self.cancel_post_registration(id, &cancellation_token).await;
1317                return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1318            }
1319
1320            cmd_tx.send(HandlerCommand::Post {
1321                id,
1322                request,
1323                deadline,
1324                cancellation_token: cancellation_token.clone(),
1325            })
1326        };
1327
1328        if let Err(e) = send_result {
1329            self.cancel_post_registration(id, &cancellation_token).await;
1330            return Err(PostRequestError::BeforeDispatch(
1331                HyperliquidError::transport(format!("post command channel closed: {e}")),
1332            ));
1333        }
1334
1335        self.await_post_response(id, rx, deadline, &cancellation_token)
1336            .await
1337            .map_err(PostRequestError::AfterDispatch)
1338    }
1339
1340    async fn await_post_response(
1341        &self,
1342        id: u64,
1343        rx: tokio::sync::oneshot::Receiver<PostResponse>,
1344        deadline: tokio::time::Instant,
1345        cancellation_token: &CancellationToken,
1346    ) -> HyperliquidResult<PostResponse> {
1347        tokio::select! {
1348            biased;
1349            result = rx => match result {
1350                Ok(response) => Ok(response),
1351                Err(_closed) => {
1352                    self.cancel_post_registration(id, cancellation_token).await;
1353                    Err(HyperliquidError::transport("post response channel closed"))
1354                },
1355            },
1356            () = tokio::time::sleep_until(deadline) => {
1357                self.cancel_post_registration(id, cancellation_token).await;
1358                Err(HyperliquidError::Timeout)
1359            },
1360        }
1361    }
1362
1363    async fn cancel_post_registration(&self, id: u64, cancellation_token: &CancellationToken) {
1364        cancellation_token.cancel();
1365        self.post_router
1366            .cancel_registration(id, cancellation_token)
1367            .await;
1368    }
1369
1370    /// Returns true if the WebSocket is actively connected.
1371    pub fn is_active(&self) -> bool {
1372        let mode = self.connection_mode.load();
1373        mode.load(Ordering::Relaxed) == ConnectionMode::Active as u8
1374    }
1375
1376    /// Returns the URL of this WebSocket client.
1377    pub fn url(&self) -> &str {
1378        self.url.expose()
1379    }
1380
1381    /// Caches multiple instruments.
1382    ///
1383    /// Clears the existing cache first, then adds all provided instruments.
1384    /// Instruments are keyed by their raw_symbol which is unique per instrument:
1385    /// - Perps use base currency (e.g., "BTC")
1386    /// - Spot uses @{pair_index} format (e.g., "@107") or slash format for PURR
1387    pub fn cache_instruments(&mut self, instruments: Vec<InstrumentAny>) {
1388        let mut map = AHashMap::new();
1389
1390        for inst in instruments {
1391            let coin = inst.raw_symbol().inner();
1392            map.insert(coin, inst);
1393        }
1394        let count = map.len();
1395        let _guard = self.cache_tx.lock();
1396        self.instruments.store(map);
1397        log::debug!("Hyperliquid instrument cache initialized with {count} instruments");
1398    }
1399
1400    /// Caches a single instrument.
1401    ///
1402    /// Any existing instrument with the same raw_symbol will be replaced.
1403    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1404        self.merge_instruments(&[instrument]);
1405    }
1406
1407    pub(crate) fn merge_instruments(&self, instruments: &[InstrumentAny]) {
1408        if instruments.is_empty() {
1409            return;
1410        }
1411
1412        let cmd_tx = self.cache_tx.lock();
1413        self.instruments.rcu(|cache| {
1414            for instrument in instruments {
1415                cache.insert(instrument.raw_symbol().inner(), instrument.clone());
1416            }
1417        });
1418
1419        // Before connect() the handler isn't running; this send will fail and that's expected
1420        // because connect() replays the instruments via InitializeInstruments
1421        for instrument in instruments {
1422            let _ = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument.clone()));
1423        }
1424    }
1425
1426    /// Returns a shared reference to the instrument cache.
1427    #[must_use]
1428    pub fn instruments_cache(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
1429        self.instruments.clone()
1430    }
1431
1432    /// Caches spot fill coin mappings for instrument lookup.
1433    ///
1434    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
1435    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
1436    /// This mapping allows the handler to look up instruments from spot fills.
1437    pub fn cache_spot_fill_coins(&self, mapping: AHashMap<Ustr, Ustr>) {
1438        if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1439            let _ = cmd_tx.send(HandlerCommand::CacheSpotFillCoins(mapping));
1440        }
1441    }
1442
1443    /// Caches a venue CLOID to client_order_id mapping for order/fill resolution.
1444    ///
1445    /// This mapping allows WebSocket order status and fill reports to be resolved back to
1446    /// the original client_order_id.
1447    ///
1448    /// This writes directly to a shared cache that the handler reads from, avoiding any
1449    /// race conditions between caching and WebSocket message processing.
1450    pub fn cache_cloid_mapping(&self, cloid: Ustr, client_order_id: ClientOrderId) {
1451        log::debug!("Caching cloid mapping: {cloid} -> {client_order_id}");
1452        self.cloid_cache.lock().insert(cloid, client_order_id);
1453    }
1454
1455    /// Removes a cloid mapping from the cache.
1456    ///
1457    /// Called on terminal order state. The cache is FIFO-bounded so missed
1458    /// removals self-evict (see GH-3972 cancel-replace drain).
1459    pub fn remove_cloid_mapping(&self, cloid: &Ustr) {
1460        if self.cloid_cache.lock().remove(cloid).is_some() {
1461            log::debug!("Removed cloid mapping: {cloid}");
1462        }
1463    }
1464
1465    /// Clears all cloid mappings from the cache.
1466    ///
1467    /// Useful for cleanup during reconnection or shutdown.
1468    pub fn clear_cloid_cache(&self) {
1469        let mut cache = self.cloid_cache.lock();
1470        let count = cache.len();
1471        cache.clear();
1472
1473        if count > 0 {
1474            log::debug!("Cleared {count} cloid mappings from cache");
1475        }
1476    }
1477
1478    /// Returns the number of cloid mappings in the cache.
1479    #[must_use]
1480    pub fn cloid_cache_len(&self) -> usize {
1481        self.cloid_cache.lock().len()
1482    }
1483
1484    /// Looks up a client_order_id by its venue CLOID.
1485    ///
1486    /// Returns `Some(ClientOrderId)` if the mapping exists, `None` otherwise.
1487    #[must_use]
1488    pub fn get_cloid_mapping(&self, cloid: &Ustr) -> Option<ClientOrderId> {
1489        self.cloid_cache.lock().get(cloid).copied()
1490    }
1491
1492    /// Gets an instrument from the cache by ID.
1493    ///
1494    /// Searches the cache for a matching instrument ID.
1495    pub fn get_instrument(&self, id: &InstrumentId) -> Option<InstrumentAny> {
1496        self.instruments
1497            .load()
1498            .values()
1499            .find(|inst| inst.id() == *id)
1500            .cloned()
1501    }
1502
1503    /// Gets an instrument from the cache by raw_symbol (coin).
1504    pub fn get_instrument_by_symbol(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1505        self.instruments.get_cloned(symbol)
1506    }
1507
1508    /// Returns the count of confirmed subscriptions.
1509    pub fn subscription_count(&self) -> usize {
1510        self.subscriptions.len()
1511    }
1512
1513    pub(crate) async fn wait_for_subscriptions_confirmed(
1514        &self,
1515        timeout: Duration,
1516    ) -> anyhow::Result<()> {
1517        tokio::time::timeout(timeout, async {
1518            while !self.subscriptions.pending_subscribe_topics().is_empty() {
1519                tokio::time::sleep(Duration::from_millis(10)).await;
1520            }
1521        })
1522        .await
1523        .map_err(|_| {
1524            anyhow::anyhow!(
1525                "Subscriptions still pending after {timeout:?}: {:?}",
1526                self.subscriptions.pending_subscribe_topics()
1527            )
1528        })
1529    }
1530
1531    /// Gets a bar type from the cache by coin and interval.
1532    ///
1533    /// This looks up the subscription key created when subscribing to bars.
1534    pub fn get_bar_type(&self, coin: &str, interval: &str) -> Option<BarType> {
1535        // Use canonical key format matching subscribe_bars
1536        let key = format!("candle:{coin}:{interval}");
1537        self.bar_types.load().get(&key).copied()
1538    }
1539
1540    /// Subscribe to L2 order book for an instrument.
1541    pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1542        self.subscribe_book_with_options(instrument_id, None, None)
1543            .await
1544    }
1545
1546    /// Subscribe to L2 order book with optional `nSigFigs` / `mantissa`
1547    /// precision controls passed through to the venue's `l2Book` stream.
1548    ///
1549    /// One venue `l2Book` stream per coin is shared with depth snapshots;
1550    /// the first logical use opens the stream and its options win. Requesting
1551    /// different options while the stream is active logs a warning.
1552    pub async fn subscribe_book_with_options(
1553        &self,
1554        instrument_id: InstrumentId,
1555        n_sig_figs: Option<u32>,
1556        mantissa: Option<u32>,
1557    ) -> anyhow::Result<()> {
1558        let instrument = self
1559            .get_instrument(&instrument_id)
1560            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1561        let coin = instrument.raw_symbol().inner();
1562
1563        let cmd_tx = self.cmd_tx.read().await;
1564
1565        // Update the handler's coin→instrument mapping for this subscription
1566        cmd_tx
1567            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1568            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1569
1570        self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Deltas, n_sig_figs, mantissa)
1571    }
1572
1573    /// Subscribes the coin's `l2Book` stream for order book deltas, opening `gate` once the
1574    /// subscription is written.
1575    ///
1576    /// A stream already open for depth snapshots is replaced so the book starts from a fresh
1577    /// snapshot. Returns without registering when `cancel` has already fired, and stops waiting
1578    /// once it fires; a queued write still completes so the venue matches the registry.
1579    pub(crate) async fn subscribe_book_gated(
1580        &self,
1581        instrument_id: InstrumentId,
1582        n_sig_figs: Option<u32>,
1583        mantissa: Option<u32>,
1584        cancel: CancellationToken,
1585        gate: SnapshotGate,
1586    ) -> Result<(), HyperliquidWsError> {
1587        let Some(instrument) = self.get_instrument(&instrument_id) else {
1588            let message = InstrumentLookupError::not_found(instrument_id).to_string();
1589            return Err(HyperliquidWsError::ClientError(message));
1590        };
1591
1592        let coin = instrument.raw_symbol().inner();
1593        let (completion, receiver) = tokio::sync::oneshot::channel();
1594        let cmd_tx = self.cmd_tx.read().await;
1595
1596        if cancel.is_cancelled() {
1597            return Ok(());
1598        }
1599
1600        cmd_tx
1601            .send(HandlerCommand::UpdateInstrument(instrument))
1602            .map_err(|e| HyperliquidWsError::ChannelSend(e.to_string()))?;
1603
1604        let registration =
1605            self.register_book_stream(coin, BookStreamUse::Deltas, n_sig_figs, mantissa);
1606
1607        let subscription = SubscriptionRequest::L2Book {
1608            coin,
1609            mantissa: registration.options.mantissa,
1610            n_sig_figs: registration.options.n_sig_figs,
1611        };
1612
1613        let replace = !registration.subscribe;
1614        let write_cancel = cancel.clone();
1615
1616        let write = move |subscription| HandlerCommand::WriteBook {
1617            subscription,
1618            replace,
1619            cancel: write_cancel,
1620            gate,
1621            completion,
1622        };
1623
1624        let sent = if registration.subscribe {
1625            self.send_reserved_command(&cmd_tx, subscription, write)
1626        } else {
1627            cmd_tx
1628                .send(write(subscription))
1629                .map_err(|e| anyhow::anyhow!("Failed to send book write command: {e}"))
1630        };
1631
1632        if let Err(e) = sent {
1633            if registration.subscribe {
1634                self.book_streams.release(&coin, BookStreamUse::Deltas);
1635            }
1636
1637            return Err(HyperliquidWsError::ChannelSend(e.to_string()));
1638        }
1639
1640        drop(cmd_tx);
1641
1642        tokio::select! {
1643            biased;
1644            () = cancel.cancelled() => Ok(()),
1645            result = receiver => result.map_err(|e| HyperliquidWsError::ChannelSend(e.to_string()))?,
1646        }
1647    }
1648
1649    /// Subscribe to order book depth-10 snapshots.
1650    ///
1651    /// Reuses the same `l2Book` WebSocket subscription as
1652    /// [`Self::subscribe_book`] and flags the handler to additionally emit
1653    /// `NautilusWsMessage::Depth` for this coin.
1654    pub async fn subscribe_book_depth(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1655        self.subscribe_book_depth_with_options(instrument_id, None, None)
1656            .await
1657    }
1658
1659    /// Subscribe to depth-10 snapshots with optional `nSigFigs` /
1660    /// `mantissa` precision controls.
1661    ///
1662    /// Shares the coin's `l2Book` stream with deltas subscribers; the first
1663    /// logical use opens the stream and its options win. Requesting different
1664    /// options while the stream is active logs a warning.
1665    pub async fn subscribe_book_depth_with_options(
1666        &self,
1667        instrument_id: InstrumentId,
1668        n_sig_figs: Option<u32>,
1669        mantissa: Option<u32>,
1670    ) -> anyhow::Result<()> {
1671        let instrument = self
1672            .get_instrument(&instrument_id)
1673            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1674        let coin = instrument.raw_symbol().inner();
1675
1676        let cmd_tx = self.cmd_tx.read().await;
1677
1678        cmd_tx
1679            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1680            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1681
1682        cmd_tx
1683            .send(HandlerCommand::SetDepthSub {
1684                coin,
1685                subscribed: true,
1686            })
1687            .map_err(|e| anyhow::anyhow!("Failed to send SetDepthSub command: {e}"))?;
1688
1689        self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Depth, n_sig_figs, mantissa)
1690    }
1691
1692    /// Unsubscribe from order book depth-10 snapshots.
1693    ///
1694    /// Clears the depth emission flag and tears down the underlying
1695    /// `l2Book` stream unless active deltas subscribers still need it.
1696    pub async fn unsubscribe_book_depth(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1697        let instrument = self
1698            .get_instrument(&instrument_id)
1699            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1700        let coin = instrument.raw_symbol().inner();
1701
1702        let cmd_tx = self.cmd_tx.read().await;
1703
1704        cmd_tx
1705            .send(HandlerCommand::SetDepthSub {
1706                coin,
1707                subscribed: false,
1708            })
1709            .map_err(|e| anyhow::anyhow!("Failed to send SetDepthSub command: {e}"))?;
1710
1711        self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Depth)
1712    }
1713
1714    /// Subscribe to best bid/offer (BBO) quotes for an instrument.
1715    pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1716        let instrument = self
1717            .get_instrument(&instrument_id)
1718            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1719        let coin = instrument.raw_symbol().inner();
1720
1721        let cmd_tx = self.cmd_tx.read().await;
1722        self.quote_streams.insert(coin, ());
1723
1724        // Update the handler's coin→instrument mapping for this subscription
1725        if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument.clone())) {
1726            self.quote_streams.remove(&coin);
1727            anyhow::bail!("Failed to send UpdateInstrument command: {e}");
1728        }
1729
1730        let subscription = SubscriptionRequest::Bbo { coin };
1731
1732        if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
1733            self.quote_streams.remove(&coin);
1734            return Err(e);
1735        }
1736        Ok(())
1737    }
1738
1739    /// Subscribe to all mid prices across markets.
1740    pub async fn subscribe_all_mids(&self) -> anyhow::Result<()> {
1741        self.subscribe_all_mids_with_dex(None).await
1742    }
1743
1744    /// Subscribe to aggregate asset contexts across all perp dexes.
1745    pub async fn subscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1746        self.send_subscription(
1747            &*self.cmd_tx.read().await,
1748            SubscriptionRequest::AllDexsAssetCtxs,
1749        )?;
1750        Ok(())
1751    }
1752
1753    /// Subscribe to all mid prices across markets, optionally scoped to a specific dex.
1754    pub async fn subscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1755        let cmd_tx = self.cmd_tx.read().await;
1756
1757        let subscription = SubscriptionRequest::AllMids {
1758            dex: dex.map(ToString::to_string),
1759        };
1760
1761        self.send_subscription(&cmd_tx, subscription)?;
1762        Ok(())
1763    }
1764
1765    /// Unsubscribe from all mid prices across markets.
1766    pub async fn unsubscribe_all_mids(&self) -> anyhow::Result<()> {
1767        self.unsubscribe_all_mids_with_dex(None).await
1768    }
1769
1770    /// Unsubscribe from aggregate asset contexts across all perp dexes.
1771    pub async fn unsubscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1772        self.send_unsubscription(
1773            &*self.cmd_tx.read().await,
1774            SubscriptionRequest::AllDexsAssetCtxs,
1775        )?;
1776        Ok(())
1777    }
1778
1779    /// Unsubscribe from all mid prices across markets, optionally scoped to a specific dex.
1780    pub async fn unsubscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1781        let cmd_tx = self.cmd_tx.read().await;
1782
1783        let subscription = SubscriptionRequest::AllMids {
1784            dex: dex.map(ToString::to_string),
1785        };
1786
1787        self.send_unsubscription(&cmd_tx, subscription)?;
1788        Ok(())
1789    }
1790
1791    /// Subscribe to trades for an instrument.
1792    pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1793        self.subscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
1794            .await
1795    }
1796
1797    /// Subscribe to complete public trades for an instrument.
1798    pub async fn subscribe_public_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1799        self.subscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
1800            .await
1801    }
1802
1803    async fn subscribe_trade_stream(
1804        &self,
1805        instrument_id: InstrumentId,
1806        stream_use: TradeStreamUse,
1807    ) -> anyhow::Result<()> {
1808        let instrument = self
1809            .get_instrument(&instrument_id)
1810            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1811        let coin = instrument.raw_symbol().inner();
1812
1813        let cmd_tx = self.cmd_tx.read().await;
1814
1815        // Update the handler's coin→instrument mapping for this subscription
1816        cmd_tx
1817            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1818            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1819
1820        // Keep registry mutations and their handler commands ordered across
1821        // concurrent generic/custom subscriptions for the same coin.
1822        let _trade_stream_guard = self.trade_stream_lock.lock();
1823        let registration = self.trade_streams.register(coin, stream_use);
1824        cmd_tx
1825            .send(HandlerCommand::UpdateTradeSubs {
1826                coin,
1827                uses: registration.uses,
1828            })
1829            .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
1830
1831        if registration.subscribe
1832            && let Err(e) = self.send_subscription(&cmd_tx, SubscriptionRequest::Trades { coin })
1833        {
1834            let rollback = self.trade_streams.release(&coin, stream_use);
1835            let _ = cmd_tx.send(HandlerCommand::UpdateTradeSubs {
1836                coin,
1837                uses: rollback.uses,
1838            });
1839            return Err(e);
1840        }
1841        Ok(())
1842    }
1843
1844    /// Subscribe to mark price updates for an instrument.
1845    pub async fn subscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1846        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1847            .await
1848    }
1849
1850    /// Subscribe to index/oracle price updates for an instrument.
1851    pub async fn subscribe_index_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1852        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1853            .await
1854    }
1855
1856    /// Subscribe to candle/bar data for a specific coin and interval.
1857    pub async fn subscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1858        let instrument_id = bar_type.instrument_id();
1859        let instrument = self
1860            .get_instrument(&instrument_id)
1861            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1862        let coin = instrument.raw_symbol().inner();
1863        let interval = bar_type_to_interval(&bar_type)?;
1864        let subscription = SubscriptionRequest::Candle { coin, interval };
1865
1866        // Cache the bar type for parsing using canonical key
1867        let key = format!("candle:{coin}:{interval}");
1868        self.bar_types.insert(key.clone(), bar_type);
1869
1870        let cmd_tx = self.cmd_tx.read().await;
1871
1872        cmd_tx
1873            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1874            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1875
1876        cmd_tx
1877            .send(HandlerCommand::AddBarType {
1878                key: key.clone(),
1879                bar_type,
1880            })
1881            .map_err(|e| anyhow::anyhow!("Failed to send AddBarType command: {e}"))?;
1882
1883        if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
1884            self.bar_types.remove(&key);
1885            let _ = cmd_tx.send(HandlerCommand::RemoveBarType { key });
1886            return Err(e);
1887        }
1888        Ok(())
1889    }
1890
1891    /// Subscribe to funding rate updates for an instrument.
1892    pub async fn subscribe_funding_rates(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1893        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1894            .await
1895    }
1896
1897    /// Subscribe to open interest updates for an instrument.
1898    pub async fn subscribe_open_interest(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1899        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1900            .await
1901    }
1902
1903    /// Subscribe to order updates for a specific user address.
1904    pub async fn subscribe_order_updates(&self, user: &str) -> anyhow::Result<()> {
1905        let subscription = SubscriptionRequest::OrderUpdates {
1906            user: user.to_string(),
1907        };
1908        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1909        Ok(())
1910    }
1911
1912    /// Subscribe to user events (fills, funding, liquidations) for a specific user address.
1913    pub async fn subscribe_user_events(&self, user: &str) -> anyhow::Result<()> {
1914        let subscription = SubscriptionRequest::UserEvents {
1915            user: user.to_string(),
1916        };
1917        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1918        Ok(())
1919    }
1920
1921    /// Subscribe to user fills for a specific user address.
1922    ///
1923    /// Note: This channel is redundant with `userEvents` which already includes fills.
1924    /// Prefer using `subscribe_user_events` or `subscribe_all_user_channels` instead.
1925    pub async fn subscribe_user_fills(&self, user: &str) -> anyhow::Result<()> {
1926        let subscription = SubscriptionRequest::UserFills {
1927            user: user.to_string(),
1928            aggregate_by_time: None,
1929        };
1930        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1931        Ok(())
1932    }
1933
1934    /// Subscribe to all user channels (order updates + user events) for convenience.
1935    ///
1936    /// Note: `userEvents` already includes fills, so we don't subscribe to `userFills`
1937    /// separately to avoid duplicate fill messages.
1938    ///
1939    /// This does **not** include opt-in TWAP custom-data channels
1940    /// (`userTwapHistory` / `userTwapSliceFills`).
1941    pub async fn subscribe_all_user_channels(&self, user: &str) -> anyhow::Result<()> {
1942        self.subscribe_order_updates(user).await?;
1943        self.subscribe_user_events(user).await?;
1944        Ok(())
1945    }
1946
1947    /// Subscribe to TWAP history for a user address (`userTwapHistory`).
1948    ///
1949    /// Opt-in custom data. The address need not be the adapter trading account.
1950    pub async fn subscribe_user_twap_history(&self, user: &str) -> anyhow::Result<()> {
1951        let subscription = SubscriptionRequest::UserTwapHistory {
1952            user: user.to_string(),
1953        };
1954        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1955        Ok(())
1956    }
1957
1958    /// Unsubscribe from TWAP history for a user address.
1959    pub async fn unsubscribe_user_twap_history(&self, user: &str) -> anyhow::Result<()> {
1960        let subscription = SubscriptionRequest::UserTwapHistory {
1961            user: user.to_string(),
1962        };
1963        self.send_unsubscription(&*self.cmd_tx.read().await, subscription)?;
1964        Ok(())
1965    }
1966
1967    /// Subscribe to TWAP slice fills for a user address (`userTwapSliceFills`).
1968    ///
1969    /// Opt-in custom data. The address need not be the adapter trading account.
1970    pub async fn subscribe_user_twap_slice_fills(&self, user: &str) -> anyhow::Result<()> {
1971        let subscription = SubscriptionRequest::UserTwapSliceFills {
1972            user: user.to_string(),
1973        };
1974        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1975        Ok(())
1976    }
1977
1978    /// Unsubscribe from TWAP slice fills for a user address.
1979    pub async fn unsubscribe_user_twap_slice_fills(&self, user: &str) -> anyhow::Result<()> {
1980        let subscription = SubscriptionRequest::UserTwapSliceFills {
1981            user: user.to_string(),
1982        };
1983        self.send_unsubscription(&*self.cmd_tx.read().await, subscription)?;
1984        Ok(())
1985    }
1986
1987    /// Unsubscribe from L2 order book for an instrument.
1988    ///
1989    /// Tears down the venue `l2Book` stream unless active depth subscribers
1990    /// still need it.
1991    pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1992        let instrument = self
1993            .get_instrument(&instrument_id)
1994            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1995        let coin = instrument.raw_symbol().inner();
1996
1997        let cmd_tx = self.cmd_tx.read().await;
1998
1999        self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Deltas)
2000    }
2001
2002    /// Resubscribes the venue `l2Book` stream for an instrument in place.
2003    ///
2004    /// Sends an unsubscribe immediately followed by a subscribe, both echoing
2005    /// the stream's original precision options (the venue matches unsubscribes
2006    /// by full payload). Registry state is left untouched so the logical
2007    /// deltas/depth uses and first-wins options survive the cycle. Used by
2008    /// stale-stream recovery, where a plain subscribe would be gated off by
2009    /// the existing registry entry.
2010    pub async fn resubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2011        let instrument = self
2012            .get_instrument(&instrument_id)
2013            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2014        let coin = instrument.raw_symbol().inner();
2015
2016        // Serialize the registry check with read-locked subscribe/unsubscribe senders
2017        let cmd_tx = self.cmd_tx.write().await;
2018
2019        let Some(options) = self.book_streams.options(&coin) else {
2020            log::debug!("Skipping l2Book resubscribe for {coin}: stream no longer registered");
2021            return Ok(());
2022        };
2023
2024        let subscription = SubscriptionRequest::L2Book {
2025            coin,
2026            mantissa: options.mantissa,
2027            n_sig_figs: options.n_sig_figs,
2028        };
2029
2030        self.send_stream_resubscribe(&cmd_tx, subscription)
2031    }
2032
2033    /// Replaces the coin's `l2Book` subscription to request a fresh snapshot, opening `gate`
2034    /// once the subscription is written.
2035    ///
2036    /// Echoes the stream's original precision options and leaves registry state untouched, as
2037    /// [`Self::resubscribe_book`] does.
2038    pub(crate) async fn resubscribe_book_gated(
2039        &self,
2040        instrument_id: InstrumentId,
2041        cancel: CancellationToken,
2042        gate: SnapshotGate,
2043    ) -> Result<(), HyperliquidWsError> {
2044        let Some(instrument) = self.get_instrument(&instrument_id) else {
2045            let message = InstrumentLookupError::not_found(instrument_id).to_string();
2046            return Err(HyperliquidWsError::ClientError(message));
2047        };
2048
2049        let coin = instrument.raw_symbol().inner();
2050        let (completion, receiver) = tokio::sync::oneshot::channel();
2051
2052        // Serialize the registry check with read-locked subscribe/unsubscribe senders
2053        let cmd_tx = self.cmd_tx.write().await;
2054
2055        if cancel.is_cancelled() {
2056            return Err(HyperliquidWsError::ClientError(
2057                "Book recovery canceled".to_string(),
2058            ));
2059        }
2060
2061        let Some(options) = self.book_streams.options(&coin) else {
2062            return Err(HyperliquidWsError::ClientError(format!(
2063                "l2Book stream for {coin} is no longer registered"
2064            )));
2065        };
2066
2067        let subscription = SubscriptionRequest::L2Book {
2068            coin,
2069            mantissa: options.mantissa,
2070            n_sig_figs: options.n_sig_figs,
2071        };
2072
2073        cmd_tx
2074            .send(HandlerCommand::WriteBook {
2075                subscription,
2076                replace: true,
2077                cancel,
2078                gate,
2079                completion,
2080            })
2081            .map_err(|e| HyperliquidWsError::ChannelSend(e.to_string()))?;
2082
2083        drop(cmd_tx);
2084
2085        receiver
2086            .await
2087            .map_err(|e| HyperliquidWsError::ChannelSend(e.to_string()))?
2088    }
2089
2090    fn send_book_stream_subscribe(
2091        &self,
2092        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2093        coin: Ustr,
2094        stream_use: BookStreamUse,
2095        n_sig_figs: Option<u32>,
2096        mantissa: Option<u32>,
2097    ) -> anyhow::Result<()> {
2098        let registration = self.register_book_stream(coin, stream_use, n_sig_figs, mantissa);
2099
2100        if registration.subscribe {
2101            let subscription = SubscriptionRequest::L2Book {
2102                coin,
2103                mantissa: registration.options.mantissa,
2104                n_sig_figs: registration.options.n_sig_figs,
2105            };
2106
2107            if let Err(e) = self.send_subscription(cmd_tx, subscription) {
2108                self.book_streams.release(&coin, stream_use);
2109                return Err(e);
2110            }
2111        }
2112
2113        Ok(())
2114    }
2115
2116    fn register_book_stream(
2117        &self,
2118        coin: Ustr,
2119        stream_use: BookStreamUse,
2120        n_sig_figs: Option<u32>,
2121        mantissa: Option<u32>,
2122    ) -> BookStreamRegistration {
2123        let registration = self.book_streams.register(
2124            coin,
2125            stream_use,
2126            BookStreamOptions {
2127                n_sig_figs,
2128                mantissa,
2129            },
2130        );
2131
2132        if registration.options_mismatch {
2133            log::warn!(
2134                "Requested l2Book options for {coin} (n_sig_figs={n_sig_figs:?}, mantissa={mantissa:?}) \
2135                differ from the active stream ({:?}), keeping active options",
2136                registration.options,
2137            );
2138        }
2139
2140        registration
2141    }
2142
2143    fn send_book_stream_unsubscribe(
2144        &self,
2145        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2146        coin: Ustr,
2147        stream_use: BookStreamUse,
2148    ) -> anyhow::Result<()> {
2149        match self.book_streams.release(&coin, stream_use) {
2150            BookStreamRelease::Unsubscribe(options) => {
2151                let subscription = SubscriptionRequest::L2Book {
2152                    coin,
2153                    mantissa: options.mantissa,
2154                    n_sig_figs: options.n_sig_figs,
2155                };
2156
2157                self.send_unsubscription(cmd_tx, subscription)?;
2158            }
2159            BookStreamRelease::Retained => {
2160                let remaining_use = match stream_use {
2161                    BookStreamUse::Deltas => "depth",
2162                    BookStreamUse::Depth => "deltas",
2163                };
2164                log::debug!("Keeping shared l2Book stream for {coin}: {remaining_use} use remains");
2165            }
2166        }
2167        Ok(())
2168    }
2169
2170    /// Unsubscribe from quote ticks for an instrument.
2171    pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2172        let instrument = self
2173            .get_instrument(&instrument_id)
2174            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2175        let coin = instrument.raw_symbol().inner();
2176
2177        let subscription = SubscriptionRequest::Bbo { coin };
2178        let cmd_tx = self.cmd_tx.read().await;
2179
2180        self.quote_streams.remove(&coin);
2181
2182        self.send_unsubscription(&cmd_tx, subscription)?;
2183        Ok(())
2184    }
2185
2186    /// Resubscribes the venue `bbo` stream for an instrument in place
2187    /// (unsubscribe immediately followed by subscribe). Used by stale-stream
2188    /// recovery.
2189    pub async fn resubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2190        let instrument = self
2191            .get_instrument(&instrument_id)
2192            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2193        let coin = instrument.raw_symbol().inner();
2194
2195        // Keep the registration check atomic with the resubscribe pair
2196        let cmd_tx = self.cmd_tx.write().await;
2197
2198        if !self.quote_streams.contains_key(&coin) {
2199            log::debug!("Skipping bbo resubscribe for {coin}: stream no longer registered");
2200            return Ok(());
2201        }
2202
2203        self.send_stream_resubscribe(&cmd_tx, SubscriptionRequest::Bbo { coin })
2204    }
2205
2206    fn send_stream_resubscribe(
2207        &self,
2208        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2209        subscription: SubscriptionRequest,
2210    ) -> anyhow::Result<()> {
2211        cmd_tx
2212            .send(HandlerCommand::Resubscribe { subscription })
2213            .map_err(|e| anyhow::anyhow!("Failed to send resubscribe command: {e}"))
2214    }
2215
2216    /// Unsubscribe from trades for an instrument.
2217    pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2218        self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
2219            .await
2220    }
2221
2222    /// Unsubscribe from complete public trades for an instrument.
2223    pub async fn unsubscribe_public_trades(
2224        &self,
2225        instrument_id: InstrumentId,
2226    ) -> anyhow::Result<()> {
2227        self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
2228            .await
2229    }
2230
2231    async fn unsubscribe_trade_stream(
2232        &self,
2233        instrument_id: InstrumentId,
2234        stream_use: TradeStreamUse,
2235    ) -> anyhow::Result<()> {
2236        let instrument = self
2237            .get_instrument(&instrument_id)
2238            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2239        let coin = instrument.raw_symbol().inner();
2240
2241        let cmd_tx = self.cmd_tx.read().await;
2242        // Keep registry mutations and their handler commands ordered across
2243        // concurrent generic/custom unsubscriptions for the same coin.
2244        let _trade_stream_guard = self.trade_stream_lock.lock();
2245        let release = self.trade_streams.release(&coin, stream_use);
2246        cmd_tx
2247            .send(HandlerCommand::UpdateTradeSubs {
2248                coin,
2249                uses: release.uses,
2250            })
2251            .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
2252
2253        if release.unsubscribe {
2254            self.send_unsubscription(&cmd_tx, SubscriptionRequest::Trades { coin })?;
2255        }
2256        Ok(())
2257    }
2258
2259    /// Unsubscribe from mark price updates for an instrument.
2260    pub async fn unsubscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2261        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
2262            .await
2263    }
2264
2265    /// Unsubscribe from index/oracle price updates for an instrument.
2266    pub async fn unsubscribe_index_prices(
2267        &self,
2268        instrument_id: InstrumentId,
2269    ) -> anyhow::Result<()> {
2270        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
2271            .await
2272    }
2273
2274    /// Unsubscribe from candle/bar data.
2275    pub async fn unsubscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
2276        let instrument_id = bar_type.instrument_id();
2277        let instrument = self
2278            .get_instrument(&instrument_id)
2279            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2280        let coin = instrument.raw_symbol().inner();
2281        let interval = bar_type_to_interval(&bar_type)?;
2282        let subscription = SubscriptionRequest::Candle { coin, interval };
2283
2284        let key = format!("candle:{coin}:{interval}");
2285        self.bar_types.remove(&key);
2286
2287        let cmd_tx = self.cmd_tx.read().await;
2288
2289        cmd_tx
2290            .send(HandlerCommand::RemoveBarType { key })
2291            .map_err(|e| anyhow::anyhow!("Failed to send RemoveBarType command: {e}"))?;
2292
2293        self.send_unsubscription(&cmd_tx, subscription)?;
2294        Ok(())
2295    }
2296
2297    /// Unsubscribe from funding rate updates for an instrument.
2298    pub async fn unsubscribe_funding_rates(
2299        &self,
2300        instrument_id: InstrumentId,
2301    ) -> anyhow::Result<()> {
2302        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
2303            .await
2304    }
2305
2306    /// Unsubscribe from open interest updates for an instrument.
2307    pub async fn unsubscribe_open_interest(
2308        &self,
2309        instrument_id: InstrumentId,
2310    ) -> anyhow::Result<()> {
2311        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
2312            .await
2313    }
2314
2315    /// Cache the ordered instrument IDs required to normalize `allDexsAssetCtxs`.
2316    ///
2317    /// Entries merge by dex: each dex in `mapping` replaces its cached entry and
2318    /// dexes absent from `mapping` keep theirs. A mapping built from the
2319    /// standard `meta` fallback covers only the standard dex, and replacing the
2320    /// whole cache with it would drop every HIP-3 context until a later complete
2321    /// build. A stale entry for a dex the venue no longer lists is harmless
2322    /// because no contexts arrive for it.
2323    pub fn cache_all_dex_asset_ctxs_instrument_ids(
2324        &self,
2325        mapping: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
2326    ) {
2327        let cmd_tx = self.cache_tx.lock();
2328        self.all_dex_asset_ctxs_instrument_ids.rcu(|cached| {
2329            cached.extend(mapping.iter().map(|(dex, ids)| (*dex, ids.clone())));
2330        });
2331
2332        if let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mapping)) {
2333            log::debug!(
2334                "Failed to send CacheAllDexAssetCtxsInstrumentIds command (handler may not be connected yet): {e}"
2335            );
2336        }
2337    }
2338
2339    async fn subscribe_asset_context_data(
2340        &self,
2341        instrument_id: InstrumentId,
2342        data_type: AssetContextDataType,
2343    ) -> anyhow::Result<()> {
2344        let instrument = self
2345            .get_instrument(&instrument_id)
2346            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2347        let coin = instrument.raw_symbol().inner();
2348
2349        let mut entry = self.asset_context_subs.entry(coin).or_default();
2350        let is_first_subscription = entry.is_empty();
2351        entry.insert(data_type);
2352        let data_types = entry.clone();
2353        drop(entry);
2354
2355        let cmd_tx = self.cmd_tx.read().await;
2356
2357        cmd_tx
2358            .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
2359            .map_err(|e| anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}"))?;
2360
2361        if is_first_subscription {
2362            log::debug!(
2363                "First asset context subscription for coin '{coin}', subscribing to ActiveAssetCtx"
2364            );
2365            let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
2366
2367            cmd_tx
2368                .send(HandlerCommand::UpdateInstrument(instrument.clone()))
2369                .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
2370
2371            if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
2372                if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
2373                    entry.remove(&data_type);
2374                    let rollback = entry.clone();
2375                    let remove_entry = entry.is_empty();
2376                    drop(entry);
2377
2378                    if remove_entry {
2379                        self.asset_context_subs.remove(&coin);
2380                    }
2381                    let _ = cmd_tx.send(HandlerCommand::UpdateAssetContextSubs {
2382                        coin,
2383                        data_types: rollback,
2384                    });
2385                }
2386                return Err(e);
2387            }
2388        } else {
2389            log::debug!(
2390                "Already subscribed to ActiveAssetCtx for coin '{coin}', adding {data_type:?} to tracked types"
2391            );
2392        }
2393
2394        Ok(())
2395    }
2396
2397    async fn unsubscribe_asset_context_data(
2398        &self,
2399        instrument_id: InstrumentId,
2400        data_type: AssetContextDataType,
2401    ) -> anyhow::Result<()> {
2402        let instrument = self
2403            .get_instrument(&instrument_id)
2404            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2405        let coin = instrument.raw_symbol().inner();
2406
2407        if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
2408            entry.remove(&data_type);
2409            let should_unsubscribe = entry.is_empty();
2410            let data_types = entry.clone();
2411            drop(entry);
2412
2413            let cmd_tx = self.cmd_tx.read().await;
2414
2415            if should_unsubscribe {
2416                self.asset_context_subs.remove(&coin);
2417
2418                log::debug!(
2419                    "Last asset context subscription removed for coin '{coin}', unsubscribing from ActiveAssetCtx"
2420                );
2421                let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
2422
2423                cmd_tx
2424                    .send(HandlerCommand::UpdateAssetContextSubs {
2425                        coin,
2426                        data_types: AHashSet::new(),
2427                    })
2428                    .map_err(|e| {
2429                        anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
2430                    })?;
2431
2432                self.send_unsubscription(&cmd_tx, subscription)?;
2433            } else {
2434                log::debug!(
2435                    "Removed {data_type:?} from tracked types for coin '{coin}', but keeping ActiveAssetCtx subscription"
2436                );
2437
2438                cmd_tx
2439                    .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
2440                    .map_err(|e| {
2441                        anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
2442                    })?;
2443            }
2444        }
2445
2446        Ok(())
2447    }
2448
2449    fn send_subscription(
2450        &self,
2451        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2452        subscription: SubscriptionRequest,
2453    ) -> anyhow::Result<()> {
2454        self.send_reserved_command(cmd_tx, subscription, |subscription| {
2455            HandlerCommand::Subscribe {
2456                subscriptions: vec![subscription],
2457            }
2458        })
2459    }
2460
2461    fn send_reserved_command(
2462        &self,
2463        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2464        subscription: SubscriptionRequest,
2465        command: impl FnOnce(SubscriptionRequest) -> HandlerCommand,
2466    ) -> anyhow::Result<()> {
2467        let key = crate::websocket::handler::subscription_to_key(&subscription);
2468        let reserved = self
2469            .rate_limits
2470            .reserve_subscription(self.client_id, &subscription)
2471            .map_err(anyhow::Error::msg)?;
2472
2473        if let Err(e) = cmd_tx.send(command(subscription)) {
2474            if reserved {
2475                self.rate_limits.release_subscription(self.client_id, &key);
2476            }
2477            anyhow::bail!("Failed to send subscribe command: {e}");
2478        }
2479        Ok(())
2480    }
2481
2482    fn send_unsubscription(
2483        &self,
2484        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2485        subscription: SubscriptionRequest,
2486    ) -> anyhow::Result<()> {
2487        cmd_tx
2488            .send(HandlerCommand::Unsubscribe {
2489                subscriptions: vec![subscription],
2490            })
2491            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))
2492    }
2493
2494    /// Receives the next message from the WebSocket handler.
2495    ///
2496    /// Returns `None` if the handler has disconnected or the receiver was already taken.
2497    pub async fn next_event(&mut self) -> Option<NautilusWsMessage> {
2498        if let Some(ref mut rx) = self.out_rx {
2499            rx.recv().await
2500        } else {
2501            None
2502        }
2503    }
2504
2505    fn release_limit_reservations(&self) {
2506        self.rate_limits.release_client(self.client_id);
2507        self.connection_permit.lock().take();
2508    }
2509}
2510
2511impl Drop for HyperliquidWebSocketClient {
2512    fn drop(&mut self) {
2513        if Arc::strong_count(&self.task_handle) == 1 {
2514            self.release_limit_reservations();
2515
2516            if self.task_handle.is_empty() {
2517                return;
2518            }
2519
2520            self.signal.store(true, Ordering::Relaxed);
2521            self.task_handle.abort();
2522
2523            if let Some(control) = &self.socket_control {
2524                control.deregister();
2525            }
2526        }
2527    }
2528}
2529
2530#[derive(Debug)]
2531pub(crate) enum PostRequestError {
2532    BeforeDispatch(HyperliquidError),
2533    AfterDispatch(HyperliquidError),
2534    Rejected {
2535        error: HyperliquidError,
2536        reason: String,
2537    },
2538}
2539
2540impl PostRequestError {
2541    fn into_error(self) -> HyperliquidError {
2542        match self {
2543            Self::BeforeDispatch(error)
2544            | Self::AfterDispatch(error)
2545            | Self::Rejected { error, .. } => error,
2546        }
2547    }
2548}
2549
2550fn cancel_errors_for_requests(
2551    errors: Vec<Option<String>>,
2552    request_count: usize,
2553) -> HyperliquidResult<Vec<Option<String>>> {
2554    if errors.is_empty() {
2555        return Ok(vec![None; request_count]);
2556    }
2557
2558    if errors.len() != request_count {
2559        return Err(HyperliquidError::exchange(format!(
2560            "Cancel orders returned {} statuses for {request_count} cancels",
2561            errors.len()
2562        )));
2563    }
2564
2565    Ok(errors)
2566}
2567
2568fn map_post_payload_error(payload: String, weight: u32) -> HyperliquidError {
2569    let lower = payload.to_ascii_lowercase();
2570    let message = format!("WebSocket post error: {payload}");
2571
2572    if starts_with_status(&lower, &["429"])
2573        || lower.contains("too many requests")
2574        || lower.contains("rate limit")
2575    {
2576        HyperliquidError::rate_limit("exchange", weight, None)
2577    } else if starts_with_status(&lower, &["401", "403"])
2578        || lower.contains("unauthorized")
2579        || lower.contains("forbidden")
2580        || lower.contains("authentication")
2581        || lower.contains("authorization")
2582        || lower.contains("invalid signature")
2583        || contains_word(&lower, "auth")
2584    {
2585        HyperliquidError::auth(message)
2586    } else if starts_with_status(&lower, &["400"]) || lower.contains("bad request") {
2587        HyperliquidError::bad_request(message)
2588    } else if starts_with_status(&lower, &["500", "502", "503", "504"]) {
2589        HyperliquidError::exchange(message)
2590    } else {
2591        HyperliquidError::exchange(payload)
2592    }
2593}
2594
2595fn hyperliquid_order_kind(
2596    order_type: OrderType,
2597    time_in_force: TimeInForce,
2598    post_only: bool,
2599    trigger_price: Option<Price>,
2600    normalize_prices_enabled: bool,
2601    price_precision: Option<u8>,
2602) -> HyperliquidResult<HyperliquidExchangeOrderKind> {
2603    match order_type {
2604        OrderType::Market => Ok(HyperliquidExchangeOrderKind::Limit {
2605            limit: HyperliquidExchangeLimitParams {
2606                tif: HyperliquidExchangeTif::Ioc,
2607            },
2608        }),
2609        OrderType::Limit => {
2610            let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2611                .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
2612            Ok(HyperliquidExchangeOrderKind::Limit {
2613                limit: HyperliquidExchangeLimitParams { tif },
2614            })
2615        }
2616        OrderType::StopMarket
2617        | OrderType::StopLimit
2618        | OrderType::MarketIfTouched
2619        | OrderType::LimitIfTouched => {
2620            let trigger_price = trigger_price.ok_or_else(|| {
2621                HyperliquidError::bad_request("Trigger orders require a trigger price")
2622            })?;
2623            let trigger_px = normalize_or_validate_wire_price(
2624                trigger_price.as_decimal(),
2625                "Trigger price",
2626                price_precision,
2627                normalize_prices_enabled,
2628            )
2629            .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
2630            let tpsl = match order_type {
2631                OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2632                OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2633                    HyperliquidExchangeTpSl::Tp
2634                }
2635                _ => unreachable!(),
2636            };
2637            let is_market = matches!(
2638                order_type,
2639                OrderType::StopMarket | OrderType::MarketIfTouched
2640            );
2641
2642            Ok(HyperliquidExchangeOrderKind::Trigger {
2643                trigger: HyperliquidExchangeTriggerParams {
2644                    is_market,
2645                    trigger_px,
2646                    tpsl,
2647                },
2648            })
2649        }
2650        _ => Err(HyperliquidError::bad_request(format!(
2651            "Order type {order_type:?} not supported"
2652        ))),
2653    }
2654}
2655
2656fn ensure_ws_action_accepted(
2657    response: &HyperliquidExchangeResponse,
2658    action_name: &str,
2659) -> HyperliquidResult<()> {
2660    if response.is_ok() {
2661        if let Some(error_msg) = extract_inner_errors(response).into_iter().flatten().next() {
2662            return Err(HyperliquidError::bad_request(format!(
2663                "{action_name} rejected: {error_msg}"
2664            )));
2665        }
2666
2667        if let Some(error_msg) = extract_inner_error(response) {
2668            return Err(HyperliquidError::bad_request(format!(
2669                "{action_name} rejected: {error_msg}"
2670            )));
2671        }
2672
2673        return Ok(());
2674    }
2675
2676    Err(HyperliquidError::bad_request(format!(
2677        "{action_name} failed: {}",
2678        extract_error_message(response)
2679    )))
2680}
2681
2682fn starts_with_status(payload: &str, statuses: &[&str]) -> bool {
2683    let trimmed = payload.trim_start();
2684    statuses
2685        .iter()
2686        .any(|status| starts_with_status_token(trimmed, status))
2687        || trimmed.strip_prefix("http").is_some_and(|rest| {
2688            let rest = rest
2689                .trim_start_matches(|c: char| c.is_ascii_whitespace() || matches!(c, ':' | '/'));
2690            statuses
2691                .iter()
2692                .any(|status| starts_with_status_token(rest, status))
2693        })
2694}
2695
2696fn starts_with_status_token(payload: &str, status: &str) -> bool {
2697    payload.strip_prefix(status).is_some_and(|rest| {
2698        rest.chars()
2699            .next()
2700            .is_none_or(|c| !c.is_ascii_alphanumeric())
2701    })
2702}
2703
2704fn contains_word(payload: &str, word: &str) -> bool {
2705    payload
2706        .split(|c: char| !c.is_ascii_alphanumeric())
2707        .any(|part| part == word)
2708}
2709
2710// Uses split_once/rsplit_once because coin names can contain colons
2711// (e.g., vault tokens `vntls:vCURSOR`)
2712fn subscription_from_topic(topic: &str) -> anyhow::Result<SubscriptionRequest> {
2713    let (kind, rest) = topic
2714        .split_once(':')
2715        .map_or((topic, None), |(k, r)| (k, Some(r)));
2716
2717    let channel = HyperliquidWsChannel::from_wire_str(kind)
2718        .ok_or_else(|| anyhow::anyhow!("Unknown subscription channel: {kind}"))?;
2719
2720    match channel {
2721        HyperliquidWsChannel::AllMids => Ok(SubscriptionRequest::AllMids {
2722            dex: rest.map(|s| s.to_string()),
2723        }),
2724        HyperliquidWsChannel::AllDexsAssetCtxs => Ok(SubscriptionRequest::AllDexsAssetCtxs),
2725        HyperliquidWsChannel::Notification => Ok(SubscriptionRequest::Notification {
2726            user: rest.context("Missing user")?.to_string(),
2727        }),
2728        HyperliquidWsChannel::WebData2 => Ok(SubscriptionRequest::WebData2 {
2729            user: rest.context("Missing user")?.to_string(),
2730        }),
2731        HyperliquidWsChannel::Candle => {
2732            // Format: candle:{coin}:{interval} - interval is last segment
2733            let rest = rest.context("Missing candle params")?;
2734            let (coin, interval_str) = rest.rsplit_once(':').context("Missing interval")?;
2735            let interval = HyperliquidBarInterval::from_str(interval_str)?;
2736            Ok(SubscriptionRequest::Candle {
2737                coin: Ustr::from(coin),
2738                interval,
2739            })
2740        }
2741        HyperliquidWsChannel::L2Book => Ok(SubscriptionRequest::L2Book {
2742            coin: Ustr::from(rest.context("Missing coin")?),
2743            mantissa: None,
2744            n_sig_figs: None,
2745        }),
2746        HyperliquidWsChannel::Trades => Ok(SubscriptionRequest::Trades {
2747            coin: Ustr::from(rest.context("Missing coin")?),
2748        }),
2749        HyperliquidWsChannel::OrderUpdates => Ok(SubscriptionRequest::OrderUpdates {
2750            user: rest.context("Missing user")?.to_string(),
2751        }),
2752        HyperliquidWsChannel::UserEvents => Ok(SubscriptionRequest::UserEvents {
2753            user: rest.context("Missing user")?.to_string(),
2754        }),
2755        HyperliquidWsChannel::UserFills => Ok(SubscriptionRequest::UserFills {
2756            user: rest.context("Missing user")?.to_string(),
2757            aggregate_by_time: None,
2758        }),
2759        HyperliquidWsChannel::UserFundings => Ok(SubscriptionRequest::UserFundings {
2760            user: rest.context("Missing user")?.to_string(),
2761        }),
2762        HyperliquidWsChannel::UserNonFundingLedgerUpdates => {
2763            Ok(SubscriptionRequest::UserNonFundingLedgerUpdates {
2764                user: rest.context("Missing user")?.to_string(),
2765            })
2766        }
2767        HyperliquidWsChannel::ActiveAssetCtx => Ok(SubscriptionRequest::ActiveAssetCtx {
2768            coin: Ustr::from(rest.context("Missing coin")?),
2769        }),
2770        HyperliquidWsChannel::ActiveSpotAssetCtx => Ok(SubscriptionRequest::ActiveSpotAssetCtx {
2771            coin: Ustr::from(rest.context("Missing coin")?),
2772        }),
2773        HyperliquidWsChannel::ActiveAssetData => {
2774            // Format: activeAssetData:{user}:{coin} - user is eth addr (no colons)
2775            let rest = rest.context("Missing params")?;
2776            let (user, coin) = rest.split_once(':').context("Missing coin")?;
2777            Ok(SubscriptionRequest::ActiveAssetData {
2778                user: user.to_string(),
2779                coin: coin.to_string(),
2780            })
2781        }
2782        HyperliquidWsChannel::UserTwapSliceFills => Ok(SubscriptionRequest::UserTwapSliceFills {
2783            user: rest.context("Missing user")?.to_string(),
2784        }),
2785        HyperliquidWsChannel::UserTwapHistory => Ok(SubscriptionRequest::UserTwapHistory {
2786            user: rest.context("Missing user")?.to_string(),
2787        }),
2788        HyperliquidWsChannel::Bbo => Ok(SubscriptionRequest::Bbo {
2789            coin: Ustr::from(rest.context("Missing coin")?),
2790        }),
2791
2792        // Response-only channels are not valid subscription topics
2793        HyperliquidWsChannel::SubscriptionResponse
2794        | HyperliquidWsChannel::User
2795        | HyperliquidWsChannel::Post
2796        | HyperliquidWsChannel::Pong
2797        | HyperliquidWsChannel::Error => {
2798            anyhow::bail!("Not a subscription channel: {kind}")
2799        }
2800    }
2801}
2802
2803#[cfg(test)]
2804mod tests {
2805    use futures_util::StreamExt;
2806    use nautilus_live::{SocketReconnectRegistry, SocketReconnectRequestOutcome};
2807    use nautilus_model::{identifiers::ClientId, instruments::stubs::crypto_perpetual_ethusdt};
2808    use nautilus_network::mode::ReconnectRequestOutcome;
2809    use nautilus_testkit::connection::race_command_with_publication;
2810    use rstest::rstest;
2811    use ustr::Ustr;
2812
2813    use super::*;
2814    use crate::{
2815        common::{
2816            consts::{HYPERLIQUID_WS_POST_INFLIGHT_MAX, HYPERLIQUID_WS_SUBSCRIPTIONS_MAX},
2817            enums::HyperliquidBarInterval,
2818        },
2819        websocket::handler::subscription_to_key,
2820    };
2821
2822    #[tokio::test]
2823    async fn initialization_waits_for_acknowledgement() {
2824        let mut ws_client = HyperliquidWebSocketClient::new(
2825            None,
2826            HyperliquidEnvironment::Testnet,
2827            None,
2828            TransportBackend::default(),
2829            None,
2830        );
2831        let observer = ws_client.clone();
2832        let mode = Arc::new(AtomicU8::new(ConnectionMode::Active as u8));
2833        let (initialized_tx, initialized_rx) = tokio::sync::oneshot::channel();
2834        let mut initialization =
2835            Box::pin(ws_client.wait_for_initialization(initialized_rx, Arc::clone(&mode)));
2836
2837        let polled =
2838            std::future::poll_fn(|cx| std::task::Poll::Ready(initialization.as_mut().poll(cx)))
2839                .await;
2840        assert!(polled.is_pending());
2841        assert!(!observer.is_active());
2842
2843        initialized_tx.send(()).unwrap();
2844        initialization.await.unwrap();
2845
2846        assert!(observer.is_active());
2847        assert!(Arc::ptr_eq(&observer.connection_mode.load_full(), &mode));
2848    }
2849
2850    #[rstest]
2851    #[case::closed(false)]
2852    #[case::timeout(true)]
2853    #[tokio::test(start_paused = true)]
2854    async fn initialization_failure_releases_runtime(#[case] timeout: bool) {
2855        let mut ws_client = HyperliquidWebSocketClient::new(
2856            None,
2857            HyperliquidEnvironment::Testnet,
2858            None,
2859            TransportBackend::default(),
2860            None,
2861        );
2862        ws_client.rate_limits = Arc::new(WebSocketRateLimits::new());
2863        let available = ws_client.rate_limits.connection_slots.available_permits();
2864        *ws_client.connection_permit.lock() = Some(
2865            Arc::clone(&ws_client.rate_limits.connection_slots)
2866                .try_acquire_owned()
2867                .unwrap(),
2868        );
2869
2870        let (_out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
2871        ws_client.out_rx = Some(out_rx);
2872        let (initialized_tx, initialized_rx) = tokio::sync::oneshot::channel();
2873        let initialized_tx = timeout.then_some(initialized_tx);
2874        let mode = Arc::new(AtomicU8::new(ConnectionMode::Active as u8));
2875        let started = tokio::time::Instant::now();
2876
2877        let error = ws_client
2878            .wait_for_initialization(initialized_rx, mode)
2879            .await
2880            .unwrap_err();
2881        drop(initialized_tx);
2882
2883        let expected = if timeout {
2884            "Hyperliquid WebSocket handler initialization timed out"
2885        } else {
2886            "Hyperliquid WebSocket handler stopped before initialization"
2887        };
2888
2889        assert_eq!(error.to_string(), expected);
2890        assert_eq!(
2891            tokio::time::Instant::now() - started,
2892            if timeout {
2893                HTTP_TIMEOUT
2894            } else {
2895                Duration::ZERO
2896            },
2897        );
2898        assert!(!ws_client.is_active());
2899        assert!(ws_client.out_rx.is_none());
2900        assert!(ws_client.task_handle.is_empty());
2901        assert!(ws_client.connection_permit.lock().is_none());
2902        assert_eq!(
2903            ws_client.rate_limits.connection_slots.available_permits(),
2904            available,
2905        );
2906    }
2907
2908    #[tokio::test]
2909    async fn cancelled_initialization_rejects_stale_acknowledgement() {
2910        let mut ws_client = HyperliquidWebSocketClient::new(
2911            None,
2912            HyperliquidEnvironment::Testnet,
2913            None,
2914            TransportBackend::default(),
2915            None,
2916        );
2917        ws_client.rate_limits = Arc::new(WebSocketRateLimits::new());
2918        let available = ws_client.rate_limits.connection_slots.available_permits();
2919        *ws_client.connection_permit.lock() = Some(
2920            Arc::clone(&ws_client.rate_limits.connection_slots)
2921                .try_acquire_owned()
2922                .unwrap(),
2923        );
2924
2925        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2926        let (stopped_tx, stopped_rx) = tokio::sync::oneshot::channel::<()>();
2927        ws_client
2928            .task_handle
2929            .spawn(async move {
2930                let _stopped_tx = stopped_tx;
2931                started_tx.send(()).unwrap();
2932                std::future::pending::<()>().await;
2933            })
2934            .unwrap();
2935
2936        started_rx.await.unwrap();
2937        let observer = ws_client.clone();
2938        let old_mode = Arc::new(AtomicU8::new(ConnectionMode::Active as u8));
2939        let (old_tx, old_rx) = tokio::sync::oneshot::channel();
2940        let mut initialization =
2941            Box::pin(ws_client.wait_for_initialization(old_rx, Arc::clone(&old_mode)));
2942        let polled =
2943            std::future::poll_fn(|cx| std::task::Poll::Ready(initialization.as_mut().poll(cx)))
2944                .await;
2945        assert!(polled.is_pending());
2946        drop(initialization);
2947        assert!(
2948            tokio::time::timeout(Duration::from_secs(1), stopped_rx)
2949                .await
2950                .unwrap()
2951                .is_err(),
2952        );
2953        assert!(ws_client.connection_permit.lock().is_none());
2954        ws_client.disconnect_locked().await.unwrap();
2955
2956        assert!(ws_client.task_handle.is_empty());
2957        assert_eq!(
2958            ws_client.rate_limits.connection_slots.available_permits(),
2959            available,
2960        );
2961
2962        let new_mode = Arc::new(AtomicU8::new(ConnectionMode::Active as u8));
2963        let (new_tx, new_rx) = tokio::sync::oneshot::channel();
2964        let mut initialization =
2965            Box::pin(ws_client.wait_for_initialization(new_rx, Arc::clone(&new_mode)));
2966        let polled =
2967            std::future::poll_fn(|cx| std::task::Poll::Ready(initialization.as_mut().poll(cx)))
2968                .await;
2969        assert!(polled.is_pending());
2970
2971        assert_eq!(old_tx.send(()), Err(()));
2972        assert!(!observer.is_active());
2973        new_tx.send(()).unwrap();
2974        initialization.await.unwrap();
2975
2976        assert!(observer.is_active());
2977        assert!(Arc::ptr_eq(
2978            &observer.connection_mode.load_full(),
2979            &new_mode
2980        ));
2981        assert!(!Arc::ptr_eq(
2982            &observer.connection_mode.load_full(),
2983            &old_mode
2984        ));
2985    }
2986
2987    #[rstest]
2988    fn cache_sender_reset_isolates_connection_generation() {
2989        let mut ws_client = HyperliquidWebSocketClient::new(
2990            Some("wss://test".to_string()),
2991            HyperliquidEnvironment::Testnet,
2992            None,
2993            TransportBackend::default(),
2994            None,
2995        );
2996        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2997        *ws_client.cache_tx.lock() = cmd_tx;
2998        let old = ws_client.clone();
2999        ws_client.reset_runtime_state();
3000        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3001
3002        old.cache_instrument(instrument.clone());
3003        ws_client.cache_instrument(instrument.clone());
3004
3005        assert!(!Arc::ptr_eq(&ws_client.cache_tx, &old.cache_tx));
3006        assert!(ws_client.cache_tx.lock().is_closed());
3007        assert!(!old.cache_tx.lock().is_closed());
3008
3009        match cmd_rx.try_recv().unwrap() {
3010            HandlerCommand::UpdateInstrument(updated) => assert_eq!(updated, instrument),
3011            command => panic!("unexpected instrument command: {command:?}"),
3012        }
3013
3014        assert!(cmd_rx.try_recv().is_err());
3015    }
3016
3017    #[rstest]
3018    fn batch_cache_merges_disjoint_updates_and_preserves_commands() {
3019        let ws_client = HyperliquidWebSocketClient::new(
3020            Some("wss://test".to_string()),
3021            HyperliquidEnvironment::Testnet,
3022            None,
3023            TransportBackend::default(),
3024            None,
3025        );
3026        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
3027        *ws_client.cache_tx.lock() = sender;
3028        let mut first = crypto_perpetual_ethusdt();
3029        first.raw_symbol = "FIRST".into();
3030        first.ts_event = 17.into();
3031        let mut second = first.clone();
3032        second.id = "BTCUSDT.HYPERLIQUID".into();
3033        second.raw_symbol = "SECOND".into();
3034        second.ts_event = 29.into();
3035        let batches = [
3036            vec![InstrumentAny::CryptoPerpetual(first)],
3037            vec![InstrumentAny::CryptoPerpetual(second)],
3038        ];
3039        let barrier = std::sync::Barrier::new(2);
3040        std::thread::scope(|scope| {
3041            for batch in &batches {
3042                let client = &ws_client;
3043                let barrier = &barrier;
3044                scope.spawn(move || {
3045                    barrier.wait();
3046                    client.merge_instruments(batch);
3047                });
3048            }
3049        });
3050
3051        assert_eq!(ws_client.instruments.len(), 2);
3052        let mut received = AHashMap::new();
3053
3054        for _ in 0..2 {
3055            let HandlerCommand::UpdateInstrument(instrument) = receiver.try_recv().unwrap() else {
3056                panic!("expected UpdateInstrument");
3057            };
3058
3059            received.insert(instrument.raw_symbol().inner(), instrument);
3060        }
3061
3062        for instrument in batches.iter().flatten() {
3063            let key = instrument.raw_symbol().inner();
3064            let expected = serde_json::to_value(instrument).unwrap();
3065            assert_eq!(
3066                serde_json::to_value(ws_client.instruments.get_cloned(&key).unwrap()).unwrap(),
3067                expected
3068            );
3069            assert_eq!(serde_json::to_value(&received[&key]).unwrap(), expected);
3070        }
3071
3072        assert!(receiver.try_recv().is_err());
3073    }
3074
3075    #[tokio::test]
3076    async fn cache_updates_reach_handler_while_recovery_holds_sender() {
3077        let ws_client = HyperliquidWebSocketClient::new(
3078            Some("wss://test".to_string()),
3079            HyperliquidEnvironment::Testnet,
3080            None,
3081            TransportBackend::default(),
3082            None,
3083        );
3084        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3085        *ws_client.cmd_tx.write().await = cmd_tx.clone();
3086        *ws_client.cache_tx.lock() = cmd_tx;
3087        ws_client
3088            .connection_mode
3089            .store(Arc::new(AtomicU8::new(ConnectionMode::Active as u8)));
3090        let writer = ws_client.cmd_tx.write().await;
3091        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3092        let instrument_id = instrument.id();
3093        let mapping =
3094            AHashMap::from_iter([(Ustr::from("test-dex"), vec![None, Some(instrument_id)])]);
3095
3096        ws_client.cache_instrument(instrument.clone());
3097        ws_client.cache_all_dex_asset_ctxs_instrument_ids(mapping.clone());
3098        drop(writer);
3099
3100        assert_eq!(
3101            ws_client
3102                .instruments
3103                .get_cloned(&instrument.raw_symbol().inner()),
3104            Some(instrument.clone())
3105        );
3106        assert_eq!(
3107            ws_client.all_dex_asset_ctxs_instrument_ids.load().as_ref(),
3108            &mapping
3109        );
3110
3111        match cmd_rx.try_recv().unwrap() {
3112            HandlerCommand::UpdateInstrument(updated) => assert_eq!(updated, instrument),
3113            command => panic!("unexpected instrument command: {command:?}"),
3114        }
3115
3116        match cmd_rx.try_recv().unwrap() {
3117            HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(updated) => {
3118                assert_eq!(updated, mapping);
3119            }
3120            command => panic!("unexpected context command: {command:?}"),
3121        }
3122
3123        assert!(cmd_rx.try_recv().is_err());
3124    }
3125
3126    #[rstest]
3127    #[case::publication(false)]
3128    #[case::readiness(true)]
3129    #[tokio::test]
3130    async fn command_at_publication_or_readiness_reaches_handler(#[case] readiness: bool) {
3131        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3132        let addr = listener.local_addr().unwrap();
3133        let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
3134
3135        let server = tokio::spawn(async move {
3136            let (socket, _) = listener.accept().await.unwrap();
3137            let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
3138            while let Some(Ok(message)) = socket.next().await {
3139                if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
3140                    let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
3141                    let _ = received_tx.send(payload);
3142                }
3143            }
3144        });
3145
3146        let url = format!("ws://{addr}");
3147
3148        let mut ws_client = HyperliquidWebSocketClient::new(
3149            Some(url),
3150            HyperliquidEnvironment::Testnet,
3151            None,
3152            TransportBackend::default(),
3153            None,
3154        );
3155        let observer = ws_client.clone();
3156
3157        let command = HandlerCommand::Subscribe {
3158            subscriptions: vec![SubscriptionRequest::Trades {
3159                coin: Ustr::from("BTC"),
3160            }],
3161        };
3162
3163        if readiness {
3164            let mut connect = Box::pin(ws_client.connect());
3165            let mut command = Some(command);
3166
3167            let result = tokio::time::timeout(
3168                Duration::from_secs(5),
3169                std::future::poll_fn(|cx| {
3170                    let polled = connect.as_mut().poll(cx);
3171
3172                    if observer.is_active()
3173                        && let Some(command) = command.take()
3174                    {
3175                        assert!(polled.is_ready(), "readiness must follow initialization");
3176                        observer.cmd_tx.try_read().unwrap().send(command).unwrap();
3177                    }
3178
3179                    polled
3180                }),
3181            )
3182            .await
3183            .expect("connect must publish readiness");
3184
3185            result.expect("connect failed");
3186            assert!(command.is_none(), "command must be sent at first readiness");
3187        } else {
3188            let publication_observer = observer.clone();
3189            race_command_with_publication(
3190                Arc::clone(&ws_client.cmd_tx),
3191                ws_client.connect(),
3192                move || {
3193                    assert!(!publication_observer.is_active());
3194                    command
3195                },
3196                || observer.is_active(),
3197            )
3198            .await
3199            .expect("connect failed");
3200        }
3201
3202        let command_sender = ws_client.cmd_tx.read().await.clone();
3203        assert!(ws_client.cache_tx.lock().same_channel(&command_sender));
3204        let received = tokio::time::timeout(Duration::from_secs(2), received_rx.recv()).await;
3205        ws_client.disconnect().await.expect("disconnect failed");
3206        server.abort();
3207
3208        let payload = received
3209            .expect("startup command must reach server")
3210            .unwrap();
3211        assert_eq!(
3212            payload,
3213            serde_json::json!({
3214                "method": "subscribe",
3215                "subscription": {
3216                    "type": "trades",
3217                    "coin": "BTC"
3218                }
3219            })
3220        );
3221    }
3222
3223    #[tokio::test]
3224    #[ignore = "connects to Hyperliquid mainnet public market data"]
3225    async fn mainnet_initialization_routes_subscription() {
3226        let http_client =
3227            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 15, None).unwrap();
3228        let instruments = http_client.request_instruments().await.unwrap();
3229        let instrument = instruments
3230            .into_iter()
3231            .find(|instrument| instrument.raw_symbol().as_str() == "BTC")
3232            .expect("BTC perpetual must exist");
3233        let instrument_id = instrument.id();
3234
3235        let mut ws_client = HyperliquidWebSocketClient::new(
3236            None,
3237            HyperliquidEnvironment::Mainnet,
3238            None,
3239            TransportBackend::default(),
3240            None,
3241        );
3242        ws_client.cache_instrument(instrument);
3243
3244        ws_client.connect().await.unwrap();
3245        assert!(ws_client.is_active());
3246        ws_client.subscribe_trades(instrument_id).await.unwrap();
3247        let received = tokio::time::timeout(Duration::from_secs(15), ws_client.next_event()).await;
3248        ws_client.disconnect().await.unwrap();
3249
3250        let Some(NautilusWsMessage::Trades(trades)) = received.unwrap() else {
3251            panic!("expected BTC trades after immediate subscription");
3252        };
3253
3254        assert!(!trades.is_empty());
3255        assert!(
3256            trades
3257                .iter()
3258                .all(|trade| trade.instrument_id == instrument_id)
3259        );
3260        assert!(!ws_client.is_active());
3261    }
3262
3263    #[rstest]
3264    #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
3265    #[case(
3266        "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
3267        "wss://stream.example.com:9443"
3268    )]
3269    fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
3270        let client = HyperliquidWebSocketClient::new(
3271            Some(url.to_string()),
3272            HyperliquidEnvironment::Testnet,
3273            None,
3274            TransportBackend::default(),
3275            None,
3276        );
3277        let debug = format!("{client:?}");
3278        assert!(debug.contains(&format!("url: {origin:?}")));
3279        assert!(!debug.contains("SYN-"));
3280        assert_eq!(client.url.expose(), url);
3281    }
3282
3283    #[rstest]
3284    fn test_debug_redacts_proxy_url() {
3285        let proxy_url = "http://user:password@proxy.example:8080";
3286        let client = HyperliquidWebSocketClient::new(
3287            Some("wss://test".to_string()),
3288            HyperliquidEnvironment::Testnet,
3289            None,
3290            TransportBackend::default(),
3291            Some(proxy_url.to_string()),
3292        );
3293
3294        let debug = format!("{client:?}");
3295
3296        assert!(debug.contains(REDACTED));
3297        assert!(!debug.contains(proxy_url));
3298    }
3299
3300    #[rstest]
3301    fn test_cache_all_dex_asset_ctxs_instrument_ids_keeps_dexes_absent_from_update() {
3302        let client = HyperliquidWebSocketClient::new(
3303            Some("wss://test".to_string()),
3304            HyperliquidEnvironment::Testnet,
3305            None,
3306            TransportBackend::default(),
3307            None,
3308        );
3309        let btc = Some(InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"));
3310        let eth = Some(InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"));
3311        let tsla = Some(InstrumentId::from("xyz:TSLA-USD-PERP.HYPERLIQUID"));
3312
3313        client.cache_all_dex_asset_ctxs_instrument_ids(AHashMap::from_iter([
3314            (Ustr::from(""), vec![btc]),
3315            (Ustr::from("xyz"), vec![tsla]),
3316        ]));
3317        // a `meta` fallback build covers only the default dex
3318        client.cache_all_dex_asset_ctxs_instrument_ids(AHashMap::from_iter([(
3319            Ustr::from(""),
3320            vec![btc, eth],
3321        )]));
3322
3323        let cached = client.all_dex_asset_ctxs_instrument_ids.load();
3324        assert_eq!(cached.len(), 2);
3325        assert_eq!(cached.get(&Ustr::from("")), Some(&vec![btc, eth]));
3326        assert_eq!(cached.get(&Ustr::from("xyz")), Some(&vec![tsla]));
3327    }
3328
3329    #[tokio::test]
3330    async fn test_drop_clone_does_not_stop_handler() {
3331        let client = HyperliquidWebSocketClient::new(
3332            Some("wss://test".to_string()),
3333            HyperliquidEnvironment::Testnet,
3334            None,
3335            TransportBackend::default(),
3336            None,
3337        );
3338        client
3339            .task_handle
3340            .insert(get_runtime().spawn(std::future::pending()));
3341        let clone = client.clone();
3342
3343        drop(clone);
3344
3345        assert!(!client.signal.load(Ordering::Acquire));
3346        assert!(!client.task_handle.is_empty());
3347    }
3348
3349    /// Generates a unique topic key for a subscription request.
3350    fn subscription_topic(sub: &SubscriptionRequest) -> String {
3351        subscription_to_key(sub)
3352    }
3353
3354    #[rstest]
3355    #[case(SubscriptionRequest::Trades { coin: "BTC".into() }, "trades:BTC")]
3356    #[case(SubscriptionRequest::Bbo { coin: "BTC".into() }, "bbo:BTC")]
3357    #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() }, "orderUpdates:0x123")]
3358    #[case(SubscriptionRequest::UserEvents { user: "0xabc".to_string() }, "userEvents:0xabc")]
3359    fn test_subscription_topic_generation(
3360        #[case] subscription: SubscriptionRequest,
3361        #[case] expected_topic: &str,
3362    ) {
3363        assert_eq!(subscription_topic(&subscription), expected_topic);
3364    }
3365
3366    #[rstest]
3367    fn test_subscription_topics_unique() {
3368        let sub1 = SubscriptionRequest::Trades { coin: "BTC".into() };
3369        let sub2 = SubscriptionRequest::Bbo { coin: "BTC".into() };
3370
3371        let topic1 = subscription_topic(&sub1);
3372        let topic2 = subscription_topic(&sub2);
3373
3374        assert_ne!(topic1, topic2);
3375    }
3376
3377    #[rstest]
3378    #[case(SubscriptionRequest::Trades { coin: "BTC".into() })]
3379    #[case(SubscriptionRequest::Bbo { coin: "ETH".into() })]
3380    #[case(SubscriptionRequest::Candle { coin: "SOL".into(), interval: HyperliquidBarInterval::OneHour })]
3381    #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() })]
3382    #[case(SubscriptionRequest::Trades { coin: "vntls:vCURSOR".into() })]
3383    #[case(SubscriptionRequest::L2Book { coin: "vntls:vCURSOR".into(), mantissa: None, n_sig_figs: None })]
3384    #[case(SubscriptionRequest::Candle { coin: "vntls:vCURSOR".into(), interval: HyperliquidBarInterval::OneHour })]
3385    fn test_subscription_reconstruction(#[case] subscription: SubscriptionRequest) {
3386        let topic = subscription_topic(&subscription);
3387        let reconstructed = subscription_from_topic(&topic).expect("Failed to reconstruct");
3388        assert_eq!(subscription_topic(&reconstructed), topic);
3389    }
3390
3391    #[rstest]
3392    fn malformed_post_payload_errors_omit_private_data() {
3393        let ws_client = HyperliquidWebSocketClient::new(
3394            None,
3395            HyperliquidEnvironment::Mainnet,
3396            None,
3397            TransportBackend::default(),
3398            None,
3399        );
3400        let fixtures: serde_json::Value = serde_json::from_str(include_str!(
3401            "../../test_data/ws_malformed_private_frames.json"
3402        ))
3403        .unwrap();
3404
3405        for payload in fixtures["post_valid_payloads"].as_array().unwrap() {
3406            let parsed = ws_client.parse_post_action_payload(payload).unwrap();
3407            assert_eq!(serde_json::to_value(parsed).unwrap(), *payload);
3408        }
3409
3410        let payload = &fixtures["post_payload"];
3411        let e = ws_client.parse_post_action_payload(payload).unwrap_err();
3412        let diagnostic = e.to_string();
3413        let unexpected =
3414            HyperliquidError::decode(ws_client.post_decode_context(payload, "unexpected_info"))
3415                .to_string();
3416
3417        assert!(matches!(e, HyperliquidError::Serde(_)));
3418        assert_eq!(
3419            diagnostic,
3420            format!(
3421                "serde error: Invalid WebSocket post response: class=action, client_id={}, payload_bytes={}, category=Data",
3422                ws_client.client_id,
3423                payload.to_string().len()
3424            )
3425        );
3426        assert_eq!(
3427            unexpected,
3428            format!(
3429                "decode error: Invalid WebSocket post response: class=action, client_id={}, payload_bytes={}, category=unexpected_info",
3430                ws_client.client_id,
3431                payload.to_string().len()
3432            )
3433        );
3434
3435        for secret in fixtures["secrets"].as_array().unwrap() {
3436            let secret = secret.as_str().unwrap();
3437            assert!(!diagnostic.contains(secret));
3438            assert!(!unexpected.contains(secret));
3439        }
3440    }
3441
3442    #[rstest]
3443    #[case("post_unexpected_status", "post_unexpected_status_bytes")]
3444    #[case("post_unexpected_status_string", "post_unexpected_status_string_bytes")]
3445    fn unknown_post_status_omits_private_data(#[case] payload_key: &str, #[case] bytes_key: &str) {
3446        let ws_client = HyperliquidWebSocketClient::new(
3447            None,
3448            HyperliquidEnvironment::Mainnet,
3449            None,
3450            TransportBackend::default(),
3451            None,
3452        );
3453        let fixtures: serde_json::Value = serde_json::from_str(include_str!(
3454            "../../test_data/ws_malformed_private_frames.json"
3455        ))
3456        .unwrap();
3457        let parsed = ws_client
3458            .parse_post_action_payload(&fixtures[payload_key])
3459            .unwrap();
3460        let cancel_errors = ws_client.cancel_response_errors(&parsed, 2);
3461        let failure = ws_client.check_post_action_response(parsed).unwrap_err();
3462
3463        let PostRequestError::AfterDispatch(e) = failure else {
3464            panic!("unknown status must remain an ambiguous dispatched result");
3465        };
3466
3467        let diagnostic = e.to_string();
3468        let debug = format!("{e:?}");
3469        let success = ws_client
3470            .parse_post_action_payload(&fixtures["post_valid_payloads"][0])
3471            .unwrap();
3472        let success = ws_client.check_post_action_response(success).unwrap();
3473        let rejected = ws_client
3474            .parse_post_action_payload(&fixtures["post_business_rejection"])
3475            .unwrap();
3476        let cancel_rejections = ws_client.cancel_response_errors(&rejected, 3);
3477        let rejected = ws_client.check_post_action_response(rejected).unwrap_err();
3478
3479        let PostRequestError::Rejected { error, reason } = rejected else {
3480            panic!("known rejection must remain rejected");
3481        };
3482
3483        assert!(matches!(&e, HyperliquidError::BadRequest(_)));
3484        assert_eq!(
3485            cancel_errors,
3486            vec![Some(format!("Cancel orders failed: {diagnostic}")); 2]
3487        );
3488        assert_eq!(
3489            cancel_rejections,
3490            vec![Some("Cancel orders failed: Known venue rejection".to_string()); 3]
3491        );
3492        assert_eq!(
3493            diagnostic,
3494            format!(
3495                "bad request: Invalid WebSocket post response: class=action, client_id={}, payload_bytes={}, category=unexpected_status",
3496                ws_client.client_id,
3497                fixtures[bytes_key].as_u64().unwrap(),
3498            )
3499        );
3500        assert_eq!(
3501            serde_json::to_value(success).unwrap(),
3502            fixtures["post_valid_payloads"][0]
3503        );
3504        assert!(matches!(&error, HyperliquidError::BadRequest(_)));
3505        assert_eq!(
3506            error.to_string(),
3507            "bad request: API error: Known venue rejection"
3508        );
3509        assert_eq!(reason, "Known venue rejection");
3510
3511        for secret in fixtures["secrets"].as_array().unwrap() {
3512            let secret = secret.as_str().unwrap();
3513            assert!(!diagnostic.contains(secret));
3514            assert!(!debug.contains(secret));
3515            assert!(
3516                cancel_errors
3517                    .iter()
3518                    .all(|reason| !reason.as_ref().unwrap().contains(secret))
3519            );
3520        }
3521    }
3522
3523    #[tokio::test]
3524    async fn malformed_submit_report_logs_omit_private_data() {
3525        let _capture = crate::websocket::handler::tests::capture_logs().await;
3526
3527        let ws_client = HyperliquidWebSocketClient::new(
3528            None,
3529            HyperliquidEnvironment::Mainnet,
3530            None,
3531            TransportBackend::default(),
3532            None,
3533        );
3534        let signer = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 10, None).unwrap();
3535        let fixtures: serde_json::Value = serde_json::from_str(include_str!(
3536            "../../test_data/ws_malformed_private_frames.json"
3537        ))
3538        .unwrap();
3539        let mut expected = Vec::new();
3540
3541        for (payload, expected_bytes) in fixtures["post_report_payloads"]
3542            .as_array()
3543            .unwrap()
3544            .iter()
3545            .zip(fixtures["post_report_payload_bytes"].as_array().unwrap())
3546        {
3547            let response: HyperliquidExchangeResponse =
3548                serde_json::from_value(payload.clone()).unwrap();
3549            ensure_ws_action_accepted(&response, "Order submission").unwrap();
3550            let payload_bytes = HyperliquidWebSocketClient::response_bytes(&response);
3551            assert_eq!(payload_bytes as u64, expected_bytes.as_u64().unwrap());
3552            let single = signer.build_submit_order_report(
3553                InstrumentId::from("BTC-PERP.HYPERLIQUID"),
3554                ClientOrderId::from("PRIVATE_ORDER_SENTINEL"),
3555                OrderSide::Buy,
3556                OrderType::Limit,
3557                Quantity::from("1"),
3558                TimeInForce::Gtc,
3559                Some(Price::from("12345")),
3560                None,
3561                response.clone(),
3562            );
3563            let batch =
3564                signer.build_submit_orders_reports(&[], HyperliquidExchangeGrouping::Na, response);
3565
3566            for e in [single.as_ref().unwrap_err(), batch.as_ref().unwrap_err()] {
3567                assert!(
3568                    fixtures["secrets"]
3569                        .as_array()
3570                        .unwrap()
3571                        .iter()
3572                        .any(|secret| e.to_string().contains(secret.as_str().unwrap()))
3573                );
3574                expected.push(format!(
3575                    "Failed to build WebSocket submit report: class=order_status, client_id={}, payload_bytes={payload_bytes}, category=conversion; awaiting WS reconciliation",
3576                    ws_client.client_id,
3577                ));
3578            }
3579
3580            let single = ws_client.resolve_submit_report(single, payload_bytes);
3581            let batch = ws_client.resolve_submit_report(batch, payload_bytes);
3582            assert!(single.is_none());
3583            assert_eq!(batch.len(), 0);
3584        }
3585
3586        let success = ws_client.resolve_submit_report(Ok(Some(42_u64)), 123);
3587        assert_eq!(success, Some(42));
3588        let messages = crate::websocket::handler::tests::captured_logs();
3589
3590        let diagnostics: Vec<_> = messages
3591            .iter()
3592            .filter(|message| {
3593                message.starts_with("Failed to build WebSocket submit report:")
3594                    && message.contains(&format!("client_id={},", ws_client.client_id))
3595            })
3596            .cloned()
3597            .collect();
3598
3599        assert_eq!(diagnostics, expected);
3600
3601        for secret in fixtures["secrets"].as_array().unwrap() {
3602            assert!(
3603                messages
3604                    .iter()
3605                    .all(|message| !message.contains(secret.as_str().unwrap()))
3606            );
3607        }
3608    }
3609
3610    #[rstest]
3611    fn test_subscription_topic_candle() {
3612        let sub = SubscriptionRequest::Candle {
3613            coin: "BTC".into(),
3614            interval: HyperliquidBarInterval::OneHour,
3615        };
3616
3617        let topic = subscription_topic(&sub);
3618        assert_eq!(topic, "candle:BTC:1h");
3619    }
3620
3621    #[rstest]
3622    fn with_state_sink_survives_clone() {
3623        let client = HyperliquidWebSocketClient::new(
3624            None,
3625            HyperliquidEnvironment::Testnet,
3626            None,
3627            TransportBackend::default(),
3628            None,
3629        )
3630        .with_state_sink(SocketStateSink::new(|_| {}));
3631
3632        let cloned = client.clone();
3633        assert!(client.socket_sink.is_some());
3634        assert!(cloned.socket_sink.is_some());
3635    }
3636
3637    #[rstest]
3638    fn clone_can_own_socket_registration() {
3639        let registry = SocketReconnectRegistry::default();
3640        let endpoint = Ustr::from("hyperliquid-data-streams");
3641        let client = HyperliquidWebSocketClient::new(
3642            None,
3643            HyperliquidEnvironment::Testnet,
3644            None,
3645            TransportBackend::default(),
3646            None,
3647        )
3648        .with_socket_control(SocketControl::with_registry(
3649            ClientId::from("HYPERLIQUID"),
3650            None,
3651            endpoint,
3652            &registry,
3653        ));
3654        let cloned = client.clone();
3655        let _sink = cloned.socket_control.as_ref().unwrap().sink();
3656        cloned
3657            .socket_control
3658            .as_ref()
3659            .unwrap()
3660            .register(|| ReconnectRequestOutcome::Accepted);
3661
3662        assert!(cloned.socket_control.is_some());
3663        assert!(cloned.socket_sink.is_none());
3664        assert!(
3665            registry
3666                .handle(ClientId::from("HYPERLIQUID"), endpoint)
3667                .is_some()
3668        );
3669
3670        client.socket_control.as_ref().unwrap().deregister();
3671        assert!(
3672            registry
3673                .handle(ClientId::from("HYPERLIQUID"), endpoint)
3674                .is_some()
3675        );
3676
3677        let handle = registry
3678            .handle(ClientId::from("HYPERLIQUID"), endpoint)
3679            .unwrap();
3680        assert_eq!(
3681            handle.request_reconnect(),
3682            SocketReconnectRequestOutcome::Accepted
3683        );
3684        drop(cloned);
3685        assert!(
3686            registry
3687                .handle(ClientId::from("HYPERLIQUID"), endpoint)
3688                .is_none()
3689        );
3690    }
3691
3692    #[rstest]
3693    fn set_post_timeout_updates_client_and_clone() {
3694        let mut client = HyperliquidWebSocketClient::new(
3695            None,
3696            HyperliquidEnvironment::Testnet,
3697            None,
3698            TransportBackend::default(),
3699            None,
3700        );
3701        let timeout = std::time::Duration::from_secs(7);
3702
3703        client.set_post_timeout(timeout);
3704
3705        assert_eq!(client.post_timeout, timeout);
3706        assert_eq!(client.clone().post_timeout, timeout);
3707    }
3708
3709    #[rstest]
3710    #[tokio::test]
3711    async fn post_action_command_without_credentials_is_not_sent() {
3712        let client = HyperliquidWebSocketClient::new(
3713            None,
3714            HyperliquidEnvironment::Testnet,
3715            None,
3716            TransportBackend::default(),
3717            None,
3718        );
3719        let signer = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 10, None).unwrap();
3720        let action = HyperliquidExchangeAction::Cancel {
3721            cancels: Vec::new(),
3722            fast: None,
3723        };
3724        let result = client.post_action_command(&signer, &action).await;
3725        let PostRequestError::BeforeDispatch(error) = result.unwrap_err() else {
3726            panic!("Expected failure before dispatch");
3727        };
3728        assert_eq!(
3729            error.to_string(),
3730            "auth error: credentials required for exchange operations"
3731        );
3732    }
3733
3734    #[rstest]
3735    #[tokio::test]
3736    async fn wait_for_subscriptions_confirmed_waits_for_pending_topics() {
3737        let client = HyperliquidWebSocketClient::new(
3738            None,
3739            HyperliquidEnvironment::Testnet,
3740            None,
3741            TransportBackend::default(),
3742            None,
3743        );
3744        let topic = "orderUpdates:0xabc";
3745        client.subscriptions.mark_subscribe(topic);
3746
3747        let pending = client
3748            .wait_for_subscriptions_confirmed(Duration::from_millis(30))
3749            .await;
3750        client.subscriptions.confirm_subscribe(topic);
3751        let confirmed = client
3752            .wait_for_subscriptions_confirmed(Duration::from_millis(30))
3753            .await;
3754
3755        assert_eq!(
3756            pending.unwrap_err().to_string(),
3757            r#"Subscriptions still pending after 30ms: ["orderUpdates:0xabc"]"#,
3758        );
3759        assert!(confirmed.is_ok());
3760    }
3761
3762    #[tokio::test]
3763    async fn failed_connect_releases_connection_slot() {
3764        let mut client = HyperliquidWebSocketClient::new(
3765            Some("invalid-websocket-url".to_string()),
3766            HyperliquidEnvironment::Testnet,
3767            None,
3768            TransportBackend::default(),
3769            None,
3770        );
3771        let available_before = client.rate_limits.connection_slots.available_permits();
3772
3773        client
3774            .connect()
3775            .await
3776            .expect_err("invalid URL should fail the connection attempt");
3777
3778        assert!(client.connection_permit.lock().is_none());
3779        assert_eq!(
3780            client.rate_limits.connection_slots.available_permits(),
3781            available_before
3782        );
3783    }
3784
3785    #[rstest]
3786    #[case::before_dispatch(false)]
3787    #[case::after_dispatch(true)]
3788    #[tokio::test(start_paused = true)]
3789    async fn post_request_timeout_preserves_dispatch_evidence(#[case] dispatched: bool) {
3790        let client = HyperliquidWebSocketClient::new(
3791            None,
3792            HyperliquidEnvironment::Testnet,
3793            None,
3794            TransportBackend::default(),
3795            None,
3796        );
3797        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3798        *client.cmd_tx.write().await = tx;
3799        let timeout = if dispatched {
3800            Duration::from_millis(100)
3801        } else {
3802            Duration::ZERO
3803        };
3804        let started = tokio::time::Instant::now();
3805        let failure = client
3806            .send_post_request_result(
3807                PostRequest::Info {
3808                    payload: serde_json::json!({"type": "meta"}),
3809                },
3810                timeout,
3811            )
3812            .await
3813            .unwrap_err();
3814
3815        assert_eq!(tokio::time::Instant::now() - started, timeout);
3816
3817        if dispatched {
3818            assert!(matches!(
3819                failure,
3820                PostRequestError::AfterDispatch(HyperliquidError::Timeout)
3821            ));
3822            let HandlerCommand::Post {
3823                id,
3824                request,
3825                deadline,
3826                cancellation_token,
3827            } = rx.try_recv().unwrap()
3828            else {
3829                panic!("Expected post command");
3830            };
3831            assert_eq!(id, 1);
3832            assert_eq!(
3833                serde_json::to_value(request).unwrap(),
3834                serde_json::json!({"type": "info", "payload": {"type": "meta"}})
3835            );
3836            assert_eq!(deadline, started + timeout);
3837            assert!(cancellation_token.is_cancelled());
3838        } else {
3839            assert!(matches!(
3840                failure,
3841                PostRequestError::BeforeDispatch(HyperliquidError::Timeout)
3842            ));
3843        }
3844        assert!(matches!(
3845            rx.try_recv(),
3846            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
3847        ));
3848    }
3849
3850    #[rstest]
3851    #[tokio::test(flavor = "multi_thread")]
3852    async fn send_post_request_times_out_while_waiting_for_inflight_slot() {
3853        let client = HyperliquidWebSocketClient::new(
3854            None,
3855            HyperliquidEnvironment::Testnet,
3856            None,
3857            TransportBackend::default(),
3858            None,
3859        );
3860        let mut receivers = Vec::with_capacity(HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3861        for offset in 0..HYPERLIQUID_WS_POST_INFLIGHT_MAX {
3862            receivers.push(
3863                client
3864                    .post_router
3865                    .register(10_000 + offset as u64)
3866                    .await
3867                    .unwrap(),
3868            );
3869        }
3870
3871        let err = client
3872            .send_post_request(
3873                PostRequest::Info {
3874                    payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
3875                },
3876                std::time::Duration::from_millis(25),
3877            )
3878            .await
3879            .expect_err("request should timeout before acquiring an inflight slot");
3880
3881        assert!(matches!(err, HyperliquidError::Timeout));
3882        assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3883    }
3884
3885    #[rstest]
3886    #[tokio::test(flavor = "multi_thread")]
3887    async fn send_post_request_shares_inflight_limit_across_clients() {
3888        let url = Some("wss://shared-post-limit.test/ws".to_string());
3889        let first = HyperliquidWebSocketClient::new(
3890            url.clone(),
3891            HyperliquidEnvironment::Testnet,
3892            None,
3893            TransportBackend::default(),
3894            None,
3895        );
3896        let second = HyperliquidWebSocketClient::new(
3897            url,
3898            HyperliquidEnvironment::Testnet,
3899            None,
3900            TransportBackend::default(),
3901            None,
3902        );
3903        let mut receivers = Vec::with_capacity(HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3904
3905        for offset in 0..HYPERLIQUID_WS_POST_INFLIGHT_MAX / 2 {
3906            receivers.push(first.post_router.register(offset as u64).await.unwrap());
3907            receivers.push(
3908                second
3909                    .post_router
3910                    .register(10_000 + offset as u64)
3911                    .await
3912                    .unwrap(),
3913            );
3914        }
3915
3916        let error = second
3917            .send_post_request(
3918                PostRequest::Info {
3919                    payload: serde_json::json!({"type": "meta"}),
3920                },
3921                Duration::from_millis(25),
3922            )
3923            .await
3924            .expect_err("shared in-flight limit should block the next post");
3925
3926        assert!(matches!(error, HyperliquidError::Timeout));
3927        assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3928    }
3929
3930    #[rstest]
3931    fn subscription_limit_is_rejected_before_queueing_across_clients() {
3932        let url = Some("wss://shared-subscription-limit.test/ws".to_string());
3933        let first = HyperliquidWebSocketClient::new(
3934            url.clone(),
3935            HyperliquidEnvironment::Testnet,
3936            None,
3937            TransportBackend::default(),
3938            None,
3939        );
3940        let second = HyperliquidWebSocketClient::new(
3941            url,
3942            HyperliquidEnvironment::Testnet,
3943            None,
3944            TransportBackend::default(),
3945            None,
3946        );
3947        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3948
3949        for index in 0..HYPERLIQUID_WS_SUBSCRIPTIONS_MAX {
3950            let subscription = SubscriptionRequest::Trades {
3951                coin: Ustr::from(&format!("COIN-{index}")),
3952            };
3953            let client = if index % 2 == 0 { &first } else { &second };
3954            client.send_subscription(&cmd_tx, subscription).unwrap();
3955        }
3956
3957        let error = first
3958            .send_subscription(
3959                &cmd_tx,
3960                SubscriptionRequest::Trades {
3961                    coin: Ustr::from("OVER-LIMIT"),
3962                },
3963            )
3964            .expect_err("shared subscription limit should reject the next subscription");
3965
3966        assert!(error.to_string().contains("at most 1000"));
3967        assert_eq!(cmd_rx.len(), HYPERLIQUID_WS_SUBSCRIPTIONS_MAX);
3968    }
3969
3970    #[rstest]
3971    #[tokio::test]
3972    async fn subscribe_book_gated_at_subscription_limit_releases_book_stream() {
3973        let client = HyperliquidWebSocketClient::new(
3974            Some("wss://gated-book-limit.test/ws".to_string()),
3975            HyperliquidEnvironment::Testnet,
3976            None,
3977            TransportBackend::default(),
3978            None,
3979        );
3980        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3981        let coin = instrument.raw_symbol().inner();
3982        client.cache_instrument(instrument.clone());
3983        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3984        *client.cmd_tx.write().await = cmd_tx.clone();
3985
3986        for index in 0..HYPERLIQUID_WS_SUBSCRIPTIONS_MAX {
3987            let subscription = SubscriptionRequest::Trades {
3988                coin: Ustr::from(&format!("COIN-{index}")),
3989            };
3990
3991            client.send_subscription(&cmd_tx, subscription).unwrap();
3992        }
3993
3994        let result = client
3995            .subscribe_book_gated(
3996                instrument.id(),
3997                None,
3998                None,
3999                CancellationToken::new(),
4000                SnapshotGate::default(),
4001            )
4002            .await;
4003
4004        let error = result.expect_err("subscription limit should reject the book stream");
4005        assert!(error.to_string().contains("at most 1000"));
4006        assert_eq!(client.book_streams.options(&coin), None);
4007    }
4008
4009    #[rstest]
4010    fn stream_resubscribe_queues_one_atomic_command() {
4011        let client = HyperliquidWebSocketClient::new(
4012            Some("wss://atomic-resubscribe.test/ws".to_string()),
4013            HyperliquidEnvironment::Testnet,
4014            None,
4015            TransportBackend::default(),
4016            None,
4017        );
4018        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
4019        let subscription = SubscriptionRequest::Bbo {
4020            coin: Ustr::from("BTC"),
4021        };
4022
4023        client
4024            .send_stream_resubscribe(&cmd_tx, subscription)
4025            .unwrap();
4026
4027        assert!(matches!(
4028            cmd_rx.try_recv(),
4029            Ok(HandlerCommand::Resubscribe { subscription })
4030                if subscription_to_key(&subscription) == "bbo:BTC"
4031        ));
4032        assert!(matches!(
4033            cmd_rx.try_recv(),
4034            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
4035        ));
4036    }
4037
4038    #[rstest]
4039    #[tokio::test(start_paused = true)]
4040    async fn send_post_request_uses_deadline_while_waiting_for_command_channel() {
4041        let client = HyperliquidWebSocketClient::new(
4042            None,
4043            HyperliquidEnvironment::Testnet,
4044            None,
4045            TransportBackend::default(),
4046            None,
4047        );
4048        let _cmd_tx = client.cmd_tx.write().await;
4049        let timeout = Duration::from_millis(100);
4050        let started = tokio::time::Instant::now();
4051
4052        let error = client
4053            .send_post_request(
4054                PostRequest::Info {
4055                    payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
4056                },
4057                timeout,
4058            )
4059            .await
4060            .unwrap_err();
4061
4062        assert!(matches!(error, HyperliquidError::Timeout));
4063        assert_eq!(tokio::time::Instant::now() - started, timeout);
4064        client
4065            .post_router
4066            .register(1)
4067            .await
4068            .expect("post ID should be reusable when timeout returns");
4069    }
4070
4071    #[rstest]
4072    #[tokio::test(start_paused = true)]
4073    async fn post_response_ready_before_deadline_wins_deadline_race() {
4074        let client = HyperliquidWebSocketClient::new(
4075            None,
4076            HyperliquidEnvironment::Testnet,
4077            None,
4078            TransportBackend::default(),
4079            None,
4080        );
4081        let id = 1;
4082        let cancellation_token = CancellationToken::new();
4083        let rx = client
4084            .post_router
4085            .register_with_cancellation(id, &cancellation_token)
4086            .await
4087            .unwrap();
4088        let payload = serde_json::json!({"type": "meta", "data": {"universe": []}});
4089        client
4090            .post_router
4091            .complete(PostResponse {
4092                id,
4093                response: PostResponsePayload::Info {
4094                    payload: payload.clone(),
4095                },
4096            })
4097            .await;
4098
4099        let response = client
4100            .await_post_response(id, rx, tokio::time::Instant::now(), &cancellation_token)
4101            .await
4102            .expect("ready response should win");
4103
4104        assert_eq!(response.id, id);
4105        let PostResponsePayload::Info {
4106            payload: response_payload,
4107        } = response.response
4108        else {
4109            panic!("expected info response");
4110        };
4111        assert_eq!(response_payload, payload);
4112    }
4113
4114    #[rstest]
4115    #[tokio::test]
4116    async fn dropping_post_request_cancels_work_and_releases_registration() {
4117        let client = HyperliquidWebSocketClient::new(
4118            None,
4119            HyperliquidEnvironment::Testnet,
4120            None,
4121            TransportBackend::default(),
4122            None,
4123        );
4124        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
4125        *client.cmd_tx.write().await = cmd_tx;
4126        let request_client = client.clone();
4127        let task = tokio::spawn(async move {
4128            request_client
4129                .send_post_request(
4130                    PostRequest::Info {
4131                        payload: serde_json::json!({"type": "userRateLimit", "user": "0x123"}),
4132                    },
4133                    Duration::from_secs(60),
4134                )
4135                .await
4136        });
4137        let command = cmd_rx.recv().await.expect("post command should be queued");
4138        let HandlerCommand::Post {
4139            id,
4140            cancellation_token,
4141            ..
4142        } = command
4143        else {
4144            panic!("expected post command");
4145        };
4146
4147        task.abort();
4148        assert!(task.await.unwrap_err().is_cancelled());
4149        assert!(cancellation_token.is_cancelled());
4150
4151        let reused = tokio::time::timeout(Duration::from_secs(1), async {
4152            loop {
4153                match client.post_router.register(id).await {
4154                    Ok(rx) => break Ok(rx),
4155                    Err(e) if e.to_string().contains("already registered") => {
4156                        tokio::task::yield_now().await;
4157                    }
4158                    Err(e) => break Err(e),
4159                }
4160            }
4161        })
4162        .await
4163        .expect("post ID should be reusable after cancellation")
4164        .expect("post ID reuse should succeed");
4165        client
4166            .post_router
4167            .cancel_registration(id, &cancellation_token)
4168            .await;
4169        client
4170            .post_router
4171            .register(id)
4172            .await
4173            .expect_err("stale cancellation must not remove the reused registration");
4174
4175        let mut receivers = vec![reused];
4176        tokio::time::timeout(Duration::from_secs(1), async {
4177            for offset in 1..HYPERLIQUID_WS_POST_INFLIGHT_MAX {
4178                receivers.push(
4179                    client
4180                        .post_router
4181                        .register(10_000 + offset as u64)
4182                        .await
4183                        .unwrap(),
4184                );
4185            }
4186        })
4187        .await
4188        .expect("cancellation should release the inflight permit");
4189
4190        assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
4191    }
4192
4193    #[rstest]
4194    #[tokio::test(start_paused = true)]
4195    async fn post_timeout_after_queueing_preserves_unknown_outcome_and_releases_registration() {
4196        let client = HyperliquidWebSocketClient::new(
4197            None,
4198            HyperliquidEnvironment::Testnet,
4199            None,
4200            TransportBackend::default(),
4201            None,
4202        );
4203        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
4204        *client.cmd_tx.write().await = cmd_tx;
4205        let request_client = client.clone();
4206        let task = tokio::spawn(async move {
4207            request_client
4208                .send_post_request(
4209                    PostRequest::Info {
4210                        payload: serde_json::json!({"type": "userRateLimit", "user": "0x123"}),
4211                    },
4212                    Duration::from_millis(100),
4213                )
4214                .await
4215        });
4216        let command = cmd_rx.recv().await.expect("post command should be queued");
4217        let HandlerCommand::Post {
4218            id,
4219            cancellation_token,
4220            ..
4221        } = command
4222        else {
4223            panic!("expected post command");
4224        };
4225
4226        let error = task.await.unwrap().unwrap_err();
4227
4228        assert!(matches!(&error, HyperliquidError::Timeout));
4229        assert!(error.is_transport_error());
4230        assert!(cancellation_token.is_cancelled());
4231        client
4232            .post_router
4233            .register(id)
4234            .await
4235            .expect("post ID should be reusable when timeout returns");
4236    }
4237
4238    #[rstest]
4239    fn cancel_errors_for_requests_accepts_empty_as_success() {
4240        let errors = cancel_errors_for_requests(Vec::new(), 2).unwrap();
4241
4242        assert_eq!(errors, vec![None, None]);
4243    }
4244
4245    #[rstest]
4246    fn cancel_errors_for_requests_rejects_status_count_mismatch() {
4247        let err = cancel_errors_for_requests(vec![None], 2).expect_err("mismatch should fail");
4248
4249        assert!(
4250            err.to_string()
4251                .contains("returned 1 statuses for 2 cancels")
4252        );
4253    }
4254
4255    #[rstest]
4256    fn test_post_payload_error_maps_rate_limit() {
4257        let err = map_post_payload_error("429 Too Many Requests".to_string(), 3);
4258
4259        assert!(matches!(
4260            err,
4261            HyperliquidError::RateLimit {
4262                scope: "exchange",
4263                weight: 3,
4264                retry_after_ms: None,
4265            }
4266        ));
4267    }
4268
4269    #[rstest]
4270    #[case("401 Unauthorized")]
4271    #[case("HTTP 403: forbidden")]
4272    #[case("invalid signature")]
4273    #[case("authentication failed")]
4274    fn test_post_payload_error_maps_auth(#[case] payload: &str) {
4275        let err = map_post_payload_error(payload.to_string(), 1);
4276
4277        assert!(matches!(err, HyperliquidError::Auth(_)));
4278    }
4279
4280    #[rstest]
4281    #[case("400 Bad Request")]
4282    #[case("HTTP 400: malformed payload")]
4283    #[case("bad request: missing action")]
4284    fn test_post_payload_error_maps_bad_request(#[case] payload: &str) {
4285        let err = map_post_payload_error(payload.to_string(), 1);
4286
4287        assert!(matches!(err, HyperliquidError::BadRequest(_)));
4288    }
4289
4290    #[rstest]
4291    #[case("500 Internal Server Error")]
4292    #[case("HTTP 503: service unavailable")]
4293    fn test_post_payload_error_maps_exchange_status(#[case] payload: &str) {
4294        let err = map_post_payload_error(payload.to_string(), 1);
4295
4296        assert!(matches!(err, HyperliquidError::Exchange(_)));
4297    }
4298
4299    #[rstest]
4300    #[case("order 429001 rejected")]
4301    #[case("asset 5001 is not tradable")]
4302    #[case("authoritative nonce window exceeded")]
4303    fn test_post_payload_error_does_not_match_embedded_codes_or_words(#[case] payload: &str) {
4304        let err = map_post_payload_error(payload.to_string(), 1);
4305
4306        assert!(matches!(err, HyperliquidError::Exchange(_)));
4307    }
4308}