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