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