From f698543e6c051dec8d075690e39dd4d0df2666e3 Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Fri, 12 Dec 2025 22:07:38 -0500 Subject: [PATCH 1/9] Adds subscription methods for allMids, notification, webdata3, twapstates and clearinghouseState. Includes ws_endpoint field and setter method for hyperclient builder. --- Cargo.lock | 39 ++++- Cargo.toml | 7 + examples/subscriptions.rs | 16 ++ src/api/mod.rs | 2 + src/api/subscription/mod.rs | 4 + src/api/subscription/sender.rs | 24 +++ src/api/subscription/ws.rs | 269 +++++++++++++++++++++++++++++++++ src/client/builder.rs | 20 ++- src/client/client.rs | 16 +- src/error.rs | 12 ++ src/example_helpers.rs | 11 +- src/types/ws.rs | 50 ++++-- 12 files changed, 447 insertions(+), 23 deletions(-) create mode 100644 examples/subscriptions.rs create mode 100644 src/api/subscription/mod.rs create mode 100644 src/api/subscription/sender.rs create mode 100644 src/api/subscription/ws.rs diff --git a/Cargo.lock b/Cargo.lock index c5db414..52dfb43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -764,7 +764,7 @@ dependencies = [ "rustls", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "tracing", "ws_stream_wasm", ] @@ -2138,6 +2138,8 @@ dependencies = [ "alloy-primitives 0.8.26", "anyhow", "async-trait", + "futures", + "futures-util", "once_cell", "reqwest", "rmp-serde", @@ -2147,6 +2149,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-test", + "tokio-tungstenite 0.28.0", "tracing", "tracing-subscriber", "url", @@ -4108,10 +4111,24 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.26.2", "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.28.0", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -4282,6 +4299,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index 130b48c..80735aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ alloy = { version = "1", features = ["full"] } alloy-primitives = { version = "0.8.0", features = ["serde"] } anyhow = "1.0" async-trait = "0.1" +futures = "0.3.31" +futures-util = { version = "0.3.31", features = ["sink"] } once_cell = "1.19" reqwest = { version = "=0.12.23", features = ["json", "rustls-tls"] } rmp-serde = "1.3.0" @@ -32,6 +34,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } thiserror = "1.0" tokio = { version = "1.38", features = ["full"] } +tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } tracing = "0.1" tracing-subscriber = "0.3.22" url = "2.5" @@ -154,6 +157,10 @@ path = "examples/advanced_order.rs" name = "twap_order" path = "examples/twap_order.rs" +[[example]] +name = "subscriptions" +path = "examples/subscriptions.rs" + [profile.release] codegen-units = 1 lto = true diff --git a/examples/subscriptions.rs b/examples/subscriptions.rs new file mode 100644 index 0000000..e2d37f2 --- /dev/null +++ b/examples/subscriptions.rs @@ -0,0 +1,16 @@ +use hyperliquid_rs::{example_helpers::testnet_client, init_tracing::init_tracing}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + init_tracing(); + + let client = testnet_client()?; + let mut rx = client.subscriptions().await.unwrap().subscribe_all_mids(None).await.unwrap(); + + while let Some(msg) = rx.recv().await { + tracing::info!("Received new message from 'allMids' subscription."); + tracing::info!("{:?}", msg); + } + + Ok(()) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 550dd73..a8cbc47 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,6 +2,8 @@ pub mod exchange; pub mod info; pub mod request_util; pub mod response; +mod subscription; +pub use subscription::SubscriptionClient; pub use request_util::{current_time_millis, SUPPORTED_INTERVALS}; pub use response::{CancelResponse, OrderResponse}; diff --git a/src/api/subscription/mod.rs b/src/api/subscription/mod.rs new file mode 100644 index 0000000..fc06bc2 --- /dev/null +++ b/src/api/subscription/mod.rs @@ -0,0 +1,4 @@ +mod sender; +mod ws; + +pub use ws::SubscriptionClient; diff --git a/src/api/subscription/sender.rs b/src/api/subscription/sender.rs new file mode 100644 index 0000000..6479311 --- /dev/null +++ b/src/api/subscription/sender.rs @@ -0,0 +1,24 @@ +use tokio::sync::broadcast::Sender; + +use crate::types::ws::{WsAllMids, WsClearinghouseState, WsNotification, WsTwapStates, WsWebData3}; + +#[derive(Clone, Default)] +pub struct StreamSenders { + pub(crate) all_mids: Option>, + pub(crate) notifications: Option>, + pub(crate) webdata3: Option>, + pub(crate) twap_states: Option>, + pub(crate) clearinghouse_state: Option> +} + +impl StreamSenders { + pub fn new() -> Self { + Self { + all_mids: None, + notifications: None, + webdata3: None, + twap_states: None, + clearinghouse_state: None + } + } +} diff --git a/src/api/subscription/ws.rs b/src/api/subscription/ws.rs new file mode 100644 index 0000000..cc74d29 --- /dev/null +++ b/src/api/subscription/ws.rs @@ -0,0 +1,269 @@ +#![allow(dead_code, unused_variables)] +use crate::api::subscription::sender::StreamSenders; +use crate::client::HyperliquidClient; +use crate::error::{HyperliquidError, Result, SubscriptionError}; +use crate::types::ws::{SubscriptionConfirmation, WsAllMids, WsClearinghouseState, WsNotification, WsTwapStates, WsWebData3}; +use crate::types::{ws::{SubscriptionResponse}}; +use futures::{stream::SplitStream, StreamExt}; +use futures_util::stream::SplitSink; +use futures_util::SinkExt; +use serde_json::json; +use tokio_tungstenite::connect_async; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::{ + sync::broadcast::{Sender, Receiver, channel} +}; +use tokio_tungstenite::{ + tungstenite:: Message, + WebSocketStream, +}; + +pub struct SubscriptionConfig { + channel_capacity: usize +} + +impl Default for SubscriptionConfig { + fn default() -> Self { + Self { + channel_capacity: 1000 + } + } +} + +/// A client providing access to Hyperliquid Subscriptions API. +pub struct SubscriptionClient<'client> { + write_stream: SplitSink>, Message>, + streams: Arc>, + client: &'client HyperliquidClient +} + +impl<'client> SubscriptionClient<'client> { + pub async fn new(ws_endpoint: String, client: &'client HyperliquidClient) -> Result { + let (ws_stream, _response) = connect_async(&ws_endpoint).await?; + let (write_stream, read_stream) = ws_stream.split(); + + let senders = Arc::new(RwLock::new(StreamSenders::new())); + + Self::spawn_read_task(senders.clone(), read_stream); + + Ok(Self { + write_stream, + streams: senders, + client + }) + } + + fn subscription_channel(&self, capacity: Option) -> (Sender, Receiver) { + channel::(capacity.unwrap_or(1000)) + } + + async fn send_and_flush(&mut self, confirmation: SubscriptionConfirmation) -> Result<()> { + self.write_stream + .send(Message::Text(serde_json::to_string(&confirmation)?.into())) + .await?; + self.write_stream.flush().await?; + Ok(()) + } + + fn spawn_read_task(senders: Arc>, mut read_stream: SplitStream>>) { + tokio::spawn(async move { + while let Some(msg) = read_stream.next().await { + if let Ok(msg) = msg { + match msg { + Message::Text(utf8_bytes) => { + let string_from_bytes = String::from_utf8(utf8_bytes.as_bytes().to_vec()).unwrap(); + let value = + serde_json::from_str::(&string_from_bytes) + .unwrap(); + + match value { + SubscriptionResponse::Error(error) => { + tracing::error!("{:?}", error); + } + SubscriptionResponse::AllMids(mids) => { + let all_mids_tx = &senders.read().await.all_mids; + if let Some(tx) = all_mids_tx { + if let Err(send_err) = tx.send(mids) { + tracing::error!("{:?}", send_err); + } + } + } + _ => {} + } + } + _ => {} + } + } + } + }); + } + + /// Subscribes to the [`WsAllMids`] websocket feed. + /// + /// # Arguments + /// * `dex` (optional) Represents the perp dex to source mids from. If not provided, + /// then the first perp dex is used. Spot mids are only included with the first perp dex. + /// + /// # Returns + /// A bounded tokio::sync::broadcast::Sender + pub async fn subscribe_all_mids(&mut self, subscription_config: Option, dex: Option) -> Result> { + if self.streams.read().await.all_mids.is_some() { + tracing::error!("Already subscribed to `AllMids`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "allMids".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = self.subscription_channel::(Some(capacity)); + + let mut subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "allMids", + }) + }; + + if let Some(opt_dex) = dex { + subscription_message.subscription["dex"] = serde_json::Value::String(opt_dex); + } + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.all_mids = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsNotification`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded tokio::sync::broadcast::Sender + pub async fn subscribe_notifications(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.notifications.is_some() { + tracing::error!("Already subscribed to `notifications`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "notifications".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = self.subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "notification", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.notifications = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsWebData3`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded tokio::sync::broadcast::Sender. + pub async fn subscribe_webdata3(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.webdata3.is_some() { + tracing::error!("Already subscribed to `webdata3`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "webData3".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = self.subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "webdata3", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.webdata3 = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsTwapStates`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded tokio::sync::broadcast::Sender. + pub async fn subscribe_twap_states(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.twap_states.is_some() { + tracing::error!("Already subscribed to `twapStates`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "twapStates".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = self.subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "twapStates", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.twap_states = Some(tx); + + Ok(rx) + } + + + /// Subscribes to the [`WsClearinghouseState`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded tokio::sync::broadcast::Sender. + pub async fn subscribe_clearinghouse_state(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.clearinghouse_state.is_some() { + tracing::error!("Already subscribed to `clearinghouseState"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "clearinghouseState".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = self.subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "clearinghouseState", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.clearinghouse_state = Some(tx); + + Ok(rx) + } +} diff --git a/src/client/builder.rs b/src/client/builder.rs index a142652..27a3ac1 100644 --- a/src/client/builder.rs +++ b/src/client/builder.rs @@ -6,6 +6,7 @@ pub struct HyperliquidClientBuilder { base_url: Option, network: Option, wallet: Option, + ws_endpoint: Option } impl Default for HyperliquidClientBuilder { @@ -22,6 +23,7 @@ impl HyperliquidClientBuilder { base_url: None, network: None, wallet: None, + ws_endpoint: None } } @@ -51,18 +53,32 @@ impl HyperliquidClientBuilder { self } + pub fn with_subscriptions(&mut self) -> &mut Self { + match &self.network { + Some(network) => { + match network { + NetworkType::Mainnet => self.ws_endpoint = Some("wss://api.hyperliquid.xyz/ws".to_string()), + NetworkType::Testnet => self.ws_endpoint = Some("wss://api.hyperliquid-testnet.xyz/ws".to_string()) + } + } + None => + panic!("Builder must first call mainnet() or testnet()") + } + self + } + #[inline] pub fn build(&self) -> Result { let base_url = self .base_url .clone() .ok_or(HyperliquidError::MissingConfiguration { - parameter: "base_url".to_owned(), + parameter: "Must create client with mainnet or testnet configuration.".to_owned(), })?; // Default to Testnet let network = self.network.clone().unwrap_or(NetworkType::Testnet); - HyperliquidClient::new(network, base_url, self.wallet.clone()) + HyperliquidClient::new(network, base_url, self.ws_endpoint.clone(), self.wallet.clone()) } } diff --git a/src/client/client.rs b/src/client/client.rs index beff54d..71273cc 100644 --- a/src/client/client.rs +++ b/src/client/client.rs @@ -1,7 +1,7 @@ use crate::{ - api::{exchange::ExchangeApi, info::InfoApi}, + api::{exchange::ExchangeApi, info::InfoApi, SubscriptionClient}, client::HyperliquidClientBuilder, - error::Result, + error::{HyperliquidError, Result}, types::chain::NetworkType, }; use alloy::signers::local::PrivateKeySigner; @@ -18,19 +18,23 @@ pub struct Inner { base_url: String, wallet: Option, network: NetworkType, + ws_endpoint: Option } impl Inner { pub fn new( network: NetworkType, base_url: String, + ws_endpoint: Option, wallet: Option, + ) -> Result { let http_client = ClientBuilder::new().build()?; Ok(Self { http_client, base_url, + ws_endpoint, wallet, network, }) @@ -41,9 +45,10 @@ impl HyperliquidClient { pub fn new( network: NetworkType, base_url: String, + ws_endpoint: Option, wallet: Option, ) -> Result { - let inner = Arc::new(Inner::new(network, base_url, wallet)?); + let inner = Arc::new(Inner::new(network, base_url, ws_endpoint, wallet)?); Ok(Self { inner }) } @@ -76,6 +81,11 @@ impl HyperliquidClient { ExchangeApi::new(self) } + pub async fn subscriptions(&self) -> Result> { + let endpoint = self.inner.ws_endpoint.clone().ok_or(HyperliquidError::MissingConfiguration { parameter: "ws_endpoint".to_string() })?; + Ok(SubscriptionClient::new(endpoint, self).await?) + } + pub fn is_mainnet(&self) -> bool { match self.inner.network { NetworkType::Mainnet => true, diff --git a/src/error.rs b/src/error.rs index 15d2dfa..2635863 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,6 +2,12 @@ use alloy::primitives::SignatureError; use std::result::Result as StdResult; use thiserror::Error; +#[derive(Error, Debug)] +pub enum SubscriptionError { + #[error("Subscription already exist for method {method}")] + SubscriptionExist { method: String } +} + #[derive(Error, Debug)] pub enum HyperliquidError { #[error("{0}")] @@ -16,6 +22,8 @@ pub enum HyperliquidError { parameter: String, reason: String, }, + #[error("{0}")] + SubscriptionError(#[from] SubscriptionError), #[error("Function requires wallet/signer.")] SignerRequired, #[error("{0}")] @@ -23,6 +31,10 @@ pub enum HyperliquidError { #[error("{0}")] RmpSerde(#[from] rmp_serde::encode::Error), #[error("{0}")] + IoError(#[from] std::io::Error), + #[error("{0}")] + TungsteniteError(#[from] tokio_tungstenite::tungstenite::Error), + #[error("{0}")] SignatureError(#[from] SignatureError), #[error("Missing configuration for parameter {parameter}")] MissingConfiguration { parameter: String }, diff --git a/src/example_helpers.rs b/src/example_helpers.rs index 32a4525..cadadf3 100644 --- a/src/example_helpers.rs +++ b/src/example_helpers.rs @@ -1,15 +1,16 @@ use alloy::signers::local::PrivateKeySigner; use crate::client::HyperliquidClient; -use crate::error::Result; use std::env; -pub fn testnet_client() -> Result { - HyperliquidClient::builder().testnet().build() +pub fn testnet_client() -> Result> { + let signer = load_signer(); + Ok(HyperliquidClient::builder().testnet().with_wallet(signer).with_subscriptions().build()?) } -pub fn mainnet_client() -> Result { - HyperliquidClient::builder().mainnet().build() +pub fn mainnet_client() -> Result> { + let signer = load_signer(); + Ok(HyperliquidClient::builder().mainnet().with_wallet(signer).with_subscriptions().build()?) } #[allow(clippy::must_use_candidate)] diff --git a/src/types/ws.rs b/src/types/ws.rs index 3711c2d..69b8447 100644 --- a/src/types/ws.rs +++ b/src/types/ws.rs @@ -56,20 +56,20 @@ pub struct WsBbo { /// WebSocket notification #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Notification { +pub struct WsNotification { pub notification: String, } /// All mid prices #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct AllMids { +pub struct WsAllMids { pub mids: HashMap, } /// Candlestick data #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Candle { +pub struct WsCandle { /// Open time in milliseconds pub t: u64, /// Close time in milliseconds @@ -316,7 +316,7 @@ pub struct WsUserTwapSliceFills { /// TWAP state #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct TwapState { +pub struct WsTwapState { pub coin: String, pub user: String, pub side: String, @@ -332,7 +332,7 @@ pub struct TwapState { /// TWAP status with description #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct TwapStatusInfo { +pub struct WsTwapStatusInfo { /// "activated" | "terminated" | "finished" | "error" pub status: String, pub description: String, @@ -342,8 +342,8 @@ pub struct TwapStatusInfo { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsTwapHistory { - pub state: TwapState, - pub status: TwapStatusInfo, + pub state: WsTwapState, + pub status: WsTwapStatusInfo, pub time: u64, } @@ -395,7 +395,7 @@ pub struct PerpDexState { /// WebSocket web data (`WebData3`) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct WebData3 { +pub struct WsWebData3 { pub user_state: UserState, pub perp_dex_states: Vec, } @@ -437,7 +437,7 @@ pub struct AssetPosition { /// Clearinghouse state #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ClearinghouseState { +pub struct WsClearinghouseState { pub asset_positions: Vec, pub margin_summary: MarginSummary, pub cross_margin_summary: MarginSummary, @@ -470,10 +470,10 @@ pub struct OpenOrders { /// TWAP states for a user #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct TwapStates { +pub struct WsTwapStates { pub dex: String, pub user: String, - pub states: Vec<(u64, TwapState)>, + pub states: Vec<(u64, WsTwapState)>, } /// WebSocket user non-funding ledger update @@ -637,3 +637,31 @@ pub struct WsSpotGenesis { pub struct WsRewardsClaim { pub amount: f64, } + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SubscriptionError { + pub data: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SubscriptionConfirmation { + pub method: String, + pub subscription: serde_json::Value, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "channel", content = "data", rename_all = "camelCase")] +pub enum SubscriptionResponse { + #[serde(rename = "error")] + Error(String), + + SubscriptionResponse(SubscriptionConfirmation), + + AllMids(WsAllMids), + + Notification(WsNotification), + + WebData3(WsWebData3), + + TwapStates() +} From 6eae621b627e142c504a16958a11ad79853edbc4 Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Sun, 14 Dec 2025 00:53:51 -0500 Subject: [PATCH 2/9] Adds remaining functions to connect to websocket feeds. --- Cargo.toml | 1 + src/api/subscription/sender.rs | 52 ++- src/api/subscription/ws.rs | 564 ++++++++++++++++++++++++++++++--- src/client/builder.rs | 4 +- src/client/client.rs | 2 +- src/error.rs | 4 +- src/types/ws.rs | 18 +- 7 files changed, 593 insertions(+), 52 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 80735aa..011cdc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ must_use_candidate = "allow" uninlined_format_args = "allow" similar_names = "allow" redundant_closure_for_method_calls = "allow" +inconsistent_struct_constructor = "allow" pedantic = { level = "warn", priority = -1 } restriction = { level = "allow", priority = -1 } diff --git a/src/api/subscription/sender.rs b/src/api/subscription/sender.rs index 6479311..b513f0c 100644 --- a/src/api/subscription/sender.rs +++ b/src/api/subscription/sender.rs @@ -1,24 +1,68 @@ use tokio::sync::broadcast::Sender; - -use crate::types::ws::{WsAllMids, WsClearinghouseState, WsNotification, WsTwapStates, WsWebData3}; +use crate::types::ws::{ + WsActiveAssetData, + WsAllMids, + WsAssetCtx, + WsBbo, + WsBook, + WsCandle, + WsClearinghouseState, + WsNotification, + WsOpenOrders, + WsTrade, + WsTwapStates, + WsUserEvent, + WsUserFills, + WsUserFunding, + WsUserNonFundingLedgerUpdate, + WsUserTwapHistory, + WsUserTwapSliceFills, + WsWebData3 +}; #[derive(Clone, Default)] pub struct StreamSenders { pub(crate) all_mids: Option>, + pub(crate) candle: Option>, + pub(crate) trades: Option>, + pub(crate) l2book: Option>, pub(crate) notifications: Option>, pub(crate) webdata3: Option>, pub(crate) twap_states: Option>, - pub(crate) clearinghouse_state: Option> + pub(crate) clearinghouse_state: Option>, + pub(crate) open_orders: Option>, + pub(crate) user_events: Option>, + pub(crate) user_fills: Option>, + pub(crate) user_funding: Option>, + pub(crate) user_non_funding_ledger_updates: Option>, + pub(crate) active_asset_ctx: Option>, + pub(crate) active_asset_data: Option>, + pub(crate) user_twap_slice_fills: Option>, + pub(crate) user_twap_history: Option>, + pub(crate) bbo: Option> } impl StreamSenders { pub fn new() -> Self { Self { all_mids: None, + candle: None, + trades: None, + l2book: None, notifications: None, webdata3: None, twap_states: None, - clearinghouse_state: None + clearinghouse_state: None, + open_orders: None, + user_events: None, + user_fills: None, + user_funding: None, + user_non_funding_ledger_updates: None, + active_asset_ctx: None, + active_asset_data: None, + user_twap_slice_fills: None, + user_twap_history: None, + bbo: None } } } diff --git a/src/api/subscription/ws.rs b/src/api/subscription/ws.rs index cc74d29..5de13fb 100644 --- a/src/api/subscription/ws.rs +++ b/src/api/subscription/ws.rs @@ -1,12 +1,34 @@ #![allow(dead_code, unused_variables)] use crate::api::subscription::sender::StreamSenders; +use crate::api::SUPPORTED_INTERVALS; use crate::client::HyperliquidClient; use crate::error::{HyperliquidError, Result, SubscriptionError}; -use crate::types::ws::{SubscriptionConfirmation, WsAllMids, WsClearinghouseState, WsNotification, WsTwapStates, WsWebData3}; +use crate::types::ws::{ + SubscriptionConfirmation, + WsActiveAssetData, + WsAllMids, + WsAssetCtx, + WsBbo, + WsBook, + WsCandle, + WsClearinghouseState, + WsNotification, + WsOpenOrders, + WsTrade, + WsTwapStates, + WsUserEvent, + WsUserFills, + WsUserFunding, + WsUserNonFundingLedgerUpdate, + WsUserTwapHistory, + WsUserTwapSliceFills, + WsWebData3 +}; use crate::types::{ws::{SubscriptionResponse}}; use futures::{stream::SplitStream, StreamExt}; use futures_util::stream::SplitSink; use futures_util::SinkExt; +use serde::Serialize; use serde_json::json; use tokio_tungstenite::connect_async; use std::sync::Arc; @@ -45,7 +67,7 @@ impl<'client> SubscriptionClient<'client> { let senders = Arc::new(RwLock::new(StreamSenders::new())); - Self::spawn_read_task(senders.clone(), read_stream); + let _ = Self::spawn_read_task(senders.clone(), read_stream).await; Ok(Self { write_stream, @@ -54,7 +76,7 @@ impl<'client> SubscriptionClient<'client> { }) } - fn subscription_channel(&self, capacity: Option) -> (Sender, Receiver) { + fn subscription_channel(capacity: Option) -> (Sender, Receiver) { channel::(capacity.unwrap_or(1000)) } @@ -63,41 +85,43 @@ impl<'client> SubscriptionClient<'client> { .send(Message::Text(serde_json::to_string(&confirmation)?.into())) .await?; self.write_stream.flush().await?; + Ok(()) } - fn spawn_read_task(senders: Arc>, mut read_stream: SplitStream>>) { - tokio::spawn(async move { - while let Some(msg) = read_stream.next().await { - if let Ok(msg) = msg { - match msg { - Message::Text(utf8_bytes) => { - let string_from_bytes = String::from_utf8(utf8_bytes.as_bytes().to_vec()).unwrap(); - let value = - serde_json::from_str::(&string_from_bytes) - .unwrap(); - - match value { - SubscriptionResponse::Error(error) => { - tracing::error!("{:?}", error); - } - SubscriptionResponse::AllMids(mids) => { - let all_mids_tx = &senders.read().await.all_mids; - if let Some(tx) = all_mids_tx { - if let Err(send_err) = tx.send(mids) { - tracing::error!("{:?}", send_err); - } - } - } - _ => {} - } + fn spawn_read_task( + senders: Arc>, + mut read_stream: SplitStream< + WebSocketStream>, + >, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Some(Ok(Message::Text(text))) = read_stream.next().await { + let value = match serde_json::from_str::(&text) { + Ok(v) => v, + Err(e) => { + tracing::error!("json error: {:?}", e); + continue; + } + }; + + match value { + SubscriptionResponse::Error(err) => { + tracing::error!("{:?}", err); + } + SubscriptionResponse::AllMids(mids) => { + if let Some(tx) = &senders.read().await.all_mids { + if let Err(e) = tx.send(mids) { + tracing::error!("{:?}", e); } - _ => {} } } + _ => {} } - }); - } + } + }) +} + /// Subscribes to the [`WsAllMids`] websocket feed. /// @@ -106,7 +130,7 @@ impl<'client> SubscriptionClient<'client> { /// then the first perp dex is used. Spot mids are only included with the first perp dex. /// /// # Returns - /// A bounded tokio::sync::broadcast::Sender + /// A bounded `tokio::sync::broadcast::Sender` pub async fn subscribe_all_mids(&mut self, subscription_config: Option, dex: Option) -> Result> { if self.streams.read().await.all_mids.is_some() { tracing::error!("Already subscribed to `AllMids`"); @@ -114,7 +138,7 @@ impl<'client> SubscriptionClient<'client> { } let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = self.subscription_channel::(Some(capacity)); + let (tx, rx) = Self::subscription_channel::(Some(capacity)); let mut subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), @@ -124,8 +148,13 @@ impl<'client> SubscriptionClient<'client> { }; if let Some(opt_dex) = dex { - subscription_message.subscription["dex"] = serde_json::Value::String(opt_dex); + if let Some(dex_obj) = subscription_message.subscription.get_mut("dex") { + if let Some(obj) = dex_obj.as_object_mut() { + obj.insert("dex".to_string(), serde_json::Value::String(opt_dex)); } + } +} + self.send_and_flush(subscription_message).await?; @@ -134,13 +163,128 @@ impl<'client> SubscriptionClient<'client> { Ok(rx) } + /// Subscribes to the [`WsCandle`] websocket feed. + /// + /// # Arguments + /// * `coin` The desired asset for returning candle data. + /// * `interval` The interval at which the candle data is returned. Supported intervals include: "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w", "1M" + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender` + pub async fn subscribe_candle( + &mut self, + subscription_config: Option, + coin: impl Into + Serialize, + interval: String + ) -> Result> { + if self.streams.read().await.candle.is_some() { + tracing::error!("Already subscribed to `candle`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "candle".to_string() })); + } + + if !SUPPORTED_INTERVALS.contains(interval.as_str()) { + return Err(HyperliquidError::InvalidRequestParameter { + method: "subscribe_candle".to_string(), + parameter: "interval".to_string(), + reason: format!("Supported intervals include: {:?}", SUPPORTED_INTERVALS) }) + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "candle", + "coin": coin, + "interval": interval + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.candle = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsBook`] websocket feed. + /// + /// # Arguments + /// * `coin` The desired asset for returning l2book data. + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender` + pub async fn subscribe_l2_book( + &mut self, + subscription_config: Option, + coin: impl Into + Serialize, + ) -> Result> { + if self.streams.read().await.l2book.is_some() { + tracing::error!("Already subscribed to `l2book`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "l2book".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "l2book", + "coin": coin, + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.l2book = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsTrade`] websocket feed. + /// + /// # Arguments + /// * `coin` The desired asset for returning trade data. + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender` + pub async fn subscribe_trades( + &mut self, + subscription_config: Option, + coin: impl Into + Serialize, + ) -> Result> { + if self.streams.read().await.trades.is_some() { + tracing::error!("Already subscribed to `trades`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "trades".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "trades", + "coin": coin, + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.trades = Some(tx); + + Ok(rx) + } + /// Subscribes to the [`WsNotification`] websocket feed. /// /// # Arguments /// * `user` /// /// # Returns - /// A bounded tokio::sync::broadcast::Sender + /// A bounded `tokio::sync::broadcast::Sender` pub async fn subscribe_notifications(&mut self, subscription_config: Option, user: impl Into) -> Result> { @@ -150,7 +294,7 @@ impl<'client> SubscriptionClient<'client> { } let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = self.subscription_channel::(Some(capacity)); + let (tx, rx) = Self::subscription_channel::(Some(capacity)); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), @@ -173,7 +317,7 @@ impl<'client> SubscriptionClient<'client> { /// * `user` /// /// # Returns - /// A bounded tokio::sync::broadcast::Sender. + /// A bounded `tokio::sync::broadcast::Sender`. pub async fn subscribe_webdata3(&mut self, subscription_config: Option, user: impl Into) -> Result> { @@ -183,7 +327,7 @@ impl<'client> SubscriptionClient<'client> { } let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = self.subscription_channel::(Some(capacity)); + let (tx, rx) = Self::subscription_channel::(Some(capacity)); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), @@ -206,7 +350,7 @@ impl<'client> SubscriptionClient<'client> { /// * `user` /// /// # Returns - /// A bounded tokio::sync::broadcast::Sender. + /// A bounded `tokio::sync::broadcast::Sender`. pub async fn subscribe_twap_states(&mut self, subscription_config: Option, user: impl Into) -> Result> { @@ -216,7 +360,7 @@ impl<'client> SubscriptionClient<'client> { } let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = self.subscription_channel::(Some(capacity)); + let (tx, rx) = Self::subscription_channel::(Some(capacity)); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), @@ -240,7 +384,7 @@ impl<'client> SubscriptionClient<'client> { /// * `user` /// /// # Returns - /// A bounded tokio::sync::broadcast::Sender. + /// A bounded `tokio::sync::broadcast::Sender`. pub async fn subscribe_clearinghouse_state(&mut self, subscription_config: Option, user: impl Into) -> Result> { @@ -250,7 +394,7 @@ impl<'client> SubscriptionClient<'client> { } let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = self.subscription_channel::(Some(capacity)); + let (tx, rx) = Self::subscription_channel::(Some(capacity)); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), @@ -266,4 +410,340 @@ impl<'client> SubscriptionClient<'client> { Ok(rx) } + + /// Subscribes to the [`WsOpenOrders`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_open_orders(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.clearinghouse_state.is_some() { + tracing::error!("Already subscribed to `openOrders"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "openOrders".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "openOrders", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.open_orders = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsUserEvent`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_user_events(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.user_events.is_some() { + tracing::error!("Already subscribed to `userEvents`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userEvents".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "userEvents", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.user_events = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsUserFills`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_user_fills(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.user_events.is_some() { + tracing::error!("Already subscribed to `userFills`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userFills".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "userFills", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.user_fills = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsUserFunding`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_user_funding(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.user_events.is_some() { + tracing::error!("Already subscribed to `userFunding`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userFunding".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "userFunding", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.user_funding = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsUserNonFundingLedgerUpdate`] websocket feed. + /// + /// # Arguments + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_user_non_funding_ledger_updates(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.user_non_funding_ledger_updates.is_some() { + tracing::error!("Already subscribed to `userNonFundingLedgerUpdates`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userNonFundingLedgerUpdates".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "userNonFundingLedgerUpdates", + "user": json!(user.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.user_non_funding_ledger_updates= Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsActiveAssetCtx`] websocket feed. + /// + /// # Arguments + /// * `coin` The symbol of the coin, i.e. the asset for receiving asset context + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_active_asset_ctx(&mut self, + subscription_config: Option, + coin: impl Into) -> Result> { + if self.streams.read().await.active_asset_ctx.is_some() { + tracing::error!("Already subscribed to `activeAssetCtx`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "activeAssetCtx".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "activeAssetCtx", + "coin": json!(coin.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.active_asset_ctx = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsActiveAssetData`] websocket feed. + /// + /// # Arguments + /// * `coin` The symbol of the coin, i.e. the asset for receiving asset context + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_active_asset_data(&mut self, + subscription_config: Option, + user: impl Into, + coin: impl Into) -> Result> { + if self.streams.read().await.active_asset_data.is_some() { + tracing::error!("Already subscribed to `activeAssetData`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "activeAssetData".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "activeAssetData", + "user": json!(user.into()), + "coin": json!(coin.into()) + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.active_asset_data = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsUserTwapSliceFills`] websocket feed. + /// + /// # Arguments + /// * `coin` The symbol of the coin, i.e. the asset for receiving asset context + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_user_twap_slice_fills(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.user_twap_slice_fills.is_some() { + tracing::error!("Already subscribed to `userTwapSliceFills`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userTwapSliceFills".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "userTwapSliceFills", + "user": json!(user.into()), + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.user_twap_slice_fills = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsUserTwapHistory`] websocket feed. + /// + /// # Arguments + /// * `coin` The symbol of the coin, i.e. the asset for receiving asset context + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_user_twap_history(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.user_twap_history.is_some() { + tracing::error!("Already subscribed to `userTwapHistory`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userTwapHistory".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "userTwapHistory", + "user": json!(user.into()), + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.user_twap_history = Some(tx); + + Ok(rx) + } + + /// Subscribes to the [`WsBbo`] websocket feed. + /// + /// # Arguments + /// * `coin` The symbol of the coin, i.e. the asset for receiving asset context + /// * `user` + /// + /// # Returns + /// A bounded `tokio::sync::broadcast::Sender`. + pub async fn subscribe_bbo(&mut self, + subscription_config: Option, + user: impl Into) -> Result> { + if self.streams.read().await.bbo.is_some() { + tracing::error!("Already subscribed to `bbo`"); + return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "bbo".to_string() })); + } + + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription: json!({ + "type": "bbo", + "user": json!(user.into()), + }) + }; + + self.send_and_flush(subscription_message).await?; + + self.streams.write().await.bbo = Some(tx); + + Ok(rx) + } } diff --git a/src/client/builder.rs b/src/client/builder.rs index 27a3ac1..cda5935 100644 --- a/src/client/builder.rs +++ b/src/client/builder.rs @@ -61,9 +61,9 @@ impl HyperliquidClientBuilder { NetworkType::Testnet => self.ws_endpoint = Some("wss://api.hyperliquid-testnet.xyz/ws".to_string()) } } - None => - panic!("Builder must first call mainnet() or testnet()") + None => self.ws_endpoint = Some("wss://api.hyperliquid-testnet.xyz/ws".to_string()) } + self } diff --git a/src/client/client.rs b/src/client/client.rs index 71273cc..75bcf5c 100644 --- a/src/client/client.rs +++ b/src/client/client.rs @@ -83,7 +83,7 @@ impl HyperliquidClient { pub async fn subscriptions(&self) -> Result> { let endpoint = self.inner.ws_endpoint.clone().ok_or(HyperliquidError::MissingConfiguration { parameter: "ws_endpoint".to_string() })?; - Ok(SubscriptionClient::new(endpoint, self).await?) + SubscriptionClient::new(endpoint, self).await } pub fn is_mainnet(&self) -> bool { diff --git a/src/error.rs b/src/error.rs index 2635863..808c05a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,5 @@ use alloy::primitives::SignatureError; -use std::result::Result as StdResult; +use std::{result::Result as StdResult, string::FromUtf8Error}; use thiserror::Error; #[derive(Error, Debug)] @@ -31,6 +31,8 @@ pub enum HyperliquidError { #[error("{0}")] RmpSerde(#[from] rmp_serde::encode::Error), #[error("{0}")] + FromUtf8Error(#[from] FromUtf8Error), + #[error("{0}")] IoError(#[from] std::io::Error), #[error("{0}")] TungsteniteError(#[from] tokio_tungstenite::tungstenite::Error), diff --git a/src/types/ws.rs b/src/types/ws.rs index 69b8447..9881988 100644 --- a/src/types/ws.rs +++ b/src/types/ws.rs @@ -461,7 +461,7 @@ pub struct Order { /// Open orders for a user #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct OpenOrders { +pub struct WsOpenOrders { pub dex: String, pub user: String, pub orders: Vec, @@ -649,6 +649,12 @@ pub struct SubscriptionConfirmation { pub subscription: serde_json::Value, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum WsAssetCtx{ + Ctx(WsActiveAssetCtx), + Data(WsActiveSpotAssetCtx) +} + #[derive(Serialize, Deserialize, Debug)] #[serde(tag = "channel", content = "data", rename_all = "camelCase")] pub enum SubscriptionResponse { @@ -663,5 +669,13 @@ pub enum SubscriptionResponse { WebData3(WsWebData3), - TwapStates() + TwapStates(WsTwapStates), + + OpenOrders(WsOpenOrders), + + UserEvents(WsUserEvent), + + UserNonFundingLedgerUpdate(WsUserNonFundingLedgerUpdate), + + AssetCtx(WsAssetCtx) } From 822e558909e7b461ced3f27ecd43bd548f905eae Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Mon, 15 Dec 2025 14:12:50 -0500 Subject: [PATCH 3/9] Resolve async lock handling. Stores subscription parameters to enable unsubscribe() --- examples/subscriptions.rs | 8 +- src/api/mod.rs | 2 +- src/api/subscription/sender.rs | 102 +++-- src/api/subscription/ws.rs | 690 +++++++++++++++++++++------------ src/client/builder.rs | 25 +- src/client/client.rs | 11 +- src/error.rs | 2 +- src/example_helpers.rs | 12 +- src/types/ws.rs | 39 +- 9 files changed, 562 insertions(+), 329 deletions(-) diff --git a/examples/subscriptions.rs b/examples/subscriptions.rs index e2d37f2..b96b4ce 100644 --- a/examples/subscriptions.rs +++ b/examples/subscriptions.rs @@ -5,7 +5,13 @@ async fn main() -> Result<(), Box> { init_tracing(); let client = testnet_client()?; - let mut rx = client.subscriptions().await.unwrap().subscribe_all_mids(None).await.unwrap(); + let mut rx = client + .subscriptions() + .await + .unwrap() + .subscribe_all_mids(None) + .await + .unwrap(); while let Some(msg) = rx.recv().await { tracing::info!("Received new message from 'allMids' subscription."); diff --git a/src/api/mod.rs b/src/api/mod.rs index a8cbc47..944adb7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,6 +4,6 @@ pub mod request_util; pub mod response; mod subscription; -pub use subscription::SubscriptionClient; pub use request_util::{current_time_millis, SUPPORTED_INTERVALS}; pub use response::{CancelResponse, OrderResponse}; +pub use subscription::SubscriptionClient; diff --git a/src/api/subscription/sender.rs b/src/api/subscription/sender.rs index b513f0c..33045cf 100644 --- a/src/api/subscription/sender.rs +++ b/src/api/subscription/sender.rs @@ -1,68 +1,60 @@ -use tokio::sync::broadcast::Sender; use crate::types::ws::{ - WsActiveAssetData, - WsAllMids, - WsAssetCtx, - WsBbo, - WsBook, - WsCandle, - WsClearinghouseState, - WsNotification, - WsOpenOrders, - WsTrade, - WsTwapStates, - WsUserEvent, - WsUserFills, - WsUserFunding, - WsUserNonFundingLedgerUpdate, - WsUserTwapHistory, - WsUserTwapSliceFills, - WsWebData3 + WsActiveAssetData, WsAllMids, WsAssetCtx, WsBbo, WsBook, WsCandle, WsClearinghouseState, + WsNotification, WsOpenOrders, WsTrade, WsTwapStates, WsUserEvent, WsUserFills, WsUserFunding, + WsUserNonFundingLedgerUpdate, WsUserTwapHistory, WsUserTwapSliceFills, WsWebData3, }; +use tokio::sync::broadcast::Sender; +/// A mapping of subscription identifiers to sender channels and subscription +/// parameters. #[derive(Clone, Default)] pub struct StreamSenders { - pub(crate) all_mids: Option>, - pub(crate) candle: Option>, - pub(crate) trades: Option>, - pub(crate) l2book: Option>, - pub(crate) notifications: Option>, - pub(crate) webdata3: Option>, - pub(crate) twap_states: Option>, - pub(crate) clearinghouse_state: Option>, - pub(crate) open_orders: Option>, - pub(crate) user_events: Option>, - pub(crate) user_fills: Option>, - pub(crate) user_funding: Option>, - pub(crate) user_non_funding_ledger_updates: Option>, - pub(crate) active_asset_ctx: Option>, - pub(crate) active_asset_data: Option>, - pub(crate) user_twap_slice_fills: Option>, - pub(crate) user_twap_history: Option>, - pub(crate) bbo: Option> + pub(crate) all_mids: (Option>, Option), + pub(crate) candle: (Option>, Option, Option), + pub(crate) trades: (Option>, Option), + pub(crate) l2book: (Option>, Option), + pub(crate) notifications: (Option>, Option), + pub(crate) webdata3: (Option>, Option), + pub(crate) twap_states: (Option>, Option), + pub(crate) clearinghouse_state: (Option>, Option), + pub(crate) open_orders: (Option>, Option), + pub(crate) user_events: (Option>, Option), + pub(crate) user_fills: (Option>, Option), + pub(crate) user_funding: (Option>, Option), + pub(crate) user_non_funding_ledger_updates: + (Option>, Option), + pub(crate) active_asset_ctx: (Option>, Option), + pub(crate) active_asset_data: ( + Option>, + Option, + Option, + ), + pub(crate) user_twap_slice_fills: (Option>, Option), + pub(crate) user_twap_history: (Option>, Option), + pub(crate) bbo: (Option>, Option), } impl StreamSenders { pub fn new() -> Self { Self { - all_mids: None, - candle: None, - trades: None, - l2book: None, - notifications: None, - webdata3: None, - twap_states: None, - clearinghouse_state: None, - open_orders: None, - user_events: None, - user_fills: None, - user_funding: None, - user_non_funding_ledger_updates: None, - active_asset_ctx: None, - active_asset_data: None, - user_twap_slice_fills: None, - user_twap_history: None, - bbo: None + all_mids: (None, None), + candle: (None, None, None), + trades: (None, None), + l2book: (None, None), + notifications: (None, None), + webdata3: (None, None), + twap_states: (None, None), + clearinghouse_state: (None, None), + open_orders: (None, None), + user_events: (None, None), + user_fills: (None, None), + user_funding: (None, None), + user_non_funding_ledger_updates: (None, None), + active_asset_ctx: (None, None), + active_asset_data: (None, None, None), + user_twap_slice_fills: (None, None), + user_twap_history: (None, None), + bbo: (None, None), } } } diff --git a/src/api/subscription/ws.rs b/src/api/subscription/ws.rs index 5de13fb..ca619af 100644 --- a/src/api/subscription/ws.rs +++ b/src/api/subscription/ws.rs @@ -4,60 +4,45 @@ use crate::api::SUPPORTED_INTERVALS; use crate::client::HyperliquidClient; use crate::error::{HyperliquidError, Result, SubscriptionError}; use crate::types::ws::{ - SubscriptionConfirmation, - WsActiveAssetData, - WsAllMids, - WsAssetCtx, - WsBbo, - WsBook, - WsCandle, - WsClearinghouseState, - WsNotification, - WsOpenOrders, - WsTrade, - WsTwapStates, - WsUserEvent, - WsUserFills, - WsUserFunding, - WsUserNonFundingLedgerUpdate, - WsUserTwapHistory, - WsUserTwapSliceFills, - WsWebData3 + SubscriptionConfirmation, WsActiveAssetData, WsAllMids, WsAssetCtx, WsBbo, WsBook, WsCandle, + WsClearinghouseState, WsNotification, WsOpenOrders, WsTrade, WsTwapStates, WsUserEvent, + WsUserFills, WsUserFunding, WsUserNonFundingLedgerUpdate, WsUserTwapHistory, + WsUserTwapSliceFills, WsWebData3, }; -use crate::types::{ws::{SubscriptionResponse}}; +use crate::types::ws::{SubscriptionKey, SubscriptionResponse}; use futures::{stream::SplitStream, StreamExt}; use futures_util::stream::SplitSink; use futures_util::SinkExt; use serde::Serialize; use serde_json::json; -use tokio_tungstenite::connect_async; use std::sync::Arc; +use tokio::sync::broadcast::{channel, Receiver, Sender}; use tokio::sync::RwLock; -use tokio::{ - sync::broadcast::{Sender, Receiver, channel} -}; -use tokio_tungstenite::{ - tungstenite:: Message, - WebSocketStream, -}; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::{tungstenite::Message, WebSocketStream}; +/// Configurable parameters for subscriptions. pub struct SubscriptionConfig { - channel_capacity: usize + // Capacity for the underlying broadcast channel + channel_capacity: usize, } impl Default for SubscriptionConfig { fn default() -> Self { Self { - channel_capacity: 1000 + channel_capacity: 1000, } } } /// A client providing access to Hyperliquid Subscriptions API. pub struct SubscriptionClient<'client> { - write_stream: SplitSink>, Message>, + write_stream: SplitSink< + WebSocketStream>, + Message, + >, streams: Arc>, - client: &'client HyperliquidClient + client: &'client HyperliquidClient, } impl<'client> SubscriptionClient<'client> { @@ -72,7 +57,7 @@ impl<'client> SubscriptionClient<'client> { Ok(Self { write_stream, streams: senders, - client + client, }) } @@ -81,7 +66,7 @@ impl<'client> SubscriptionClient<'client> { } async fn send_and_flush(&mut self, confirmation: SubscriptionConfirmation) -> Result<()> { - self.write_stream + self.write_stream .send(Message::Text(serde_json::to_string(&confirmation)?.into())) .await?; self.write_stream.flush().await?; @@ -89,39 +74,38 @@ impl<'client> SubscriptionClient<'client> { Ok(()) } - fn spawn_read_task( - senders: Arc>, - mut read_stream: SplitStream< - WebSocketStream>, - >, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - while let Some(Ok(Message::Text(text))) = read_stream.next().await { - let value = match serde_json::from_str::(&text) { - Ok(v) => v, - Err(e) => { - tracing::error!("json error: {:?}", e); - continue; - } - }; + fn spawn_read_task( + senders: Arc>, + mut read_stream: SplitStream< + WebSocketStream>, + >, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Some(Ok(Message::Text(text))) = read_stream.next().await { + let value = match serde_json::from_str::(&text) { + Ok(v) => v, + Err(e) => { + tracing::error!("json error: {:?}", e); + continue; + } + }; - match value { - SubscriptionResponse::Error(err) => { - tracing::error!("{:?}", err); - } - SubscriptionResponse::AllMids(mids) => { - if let Some(tx) = &senders.read().await.all_mids { - if let Err(e) = tx.send(mids) { - tracing::error!("{:?}", e); + match value { + SubscriptionResponse::Error(err) => { + tracing::error!("{:?}", err); + } + SubscriptionResponse::AllMids(mids) => { + if let (Some(tx), Some(dex)) = &senders.read().await.all_mids { + if let Err(e) = tx.send(mids) { + tracing::error!("{:?}", e); + } } } + _ => {} } - _ => {} } - } - }) -} - + }) + } /// Subscribes to the [`WsAllMids`] websocket feed. /// @@ -131,34 +115,49 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender` - pub async fn subscribe_all_mids(&mut self, subscription_config: Option, dex: Option) -> Result> { - if self.streams.read().await.all_mids.is_some() { - tracing::error!("Already subscribed to `AllMids`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "allMids".to_string() })); - } - - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - let mut subscription_message = SubscriptionConfirmation { + pub async fn subscribe_all_mids( + &mut self, + subscription_config: Option, + dex: Option, + ) -> Result> { + let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "allMids", - }) + }), }; - if let Some(opt_dex) = dex { - if let Some(dex_obj) = subscription_message.subscription.get_mut("dex") { - if let Some(obj) = dex_obj.as_object_mut() { - obj.insert("dex".to_string(), serde_json::Value::String(opt_dex)); - } - } -} + if let Some(dex_param) = dex.clone() { + if let Some(dex_obj) = subscription_message.clone().subscription.get_mut("dex") { + if let Some(obj) = dex_obj.as_object_mut() { + let val = serde_json::Value::String(dex_param); + obj.insert("dex".to_string(), val.clone()); + } + } + } self.send_and_flush(subscription_message).await?; - self.streams.write().await.all_mids = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + { + let write_lock = &mut self.streams.write().await.all_mids; + // let (mut opt_tx, mut opt_dex) = write_lock; + + if write_lock.0.is_some() { + tracing::error!("Already subscribed to `AllMids`"); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "allMids".to_string(), + }, + )); + } + + write_lock.1 = dex; + write_lock.0 = Some(tx); + } Ok(rx) } @@ -167,43 +166,55 @@ impl<'client> SubscriptionClient<'client> { /// /// # Arguments /// * `coin` The desired asset for returning candle data. - /// * `interval` The interval at which the candle data is returned. Supported intervals include: "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w", "1M" + /// * `interval` The interval at which the candle data is returned. Supported intervals include: "1m", "3m", + /// "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w", "1M" /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender` pub async fn subscribe_candle( &mut self, subscription_config: Option, - coin: impl Into + Serialize, - interval: String + coin: impl Into + Serialize + Clone, + interval: String, ) -> Result> { - if self.streams.read().await.candle.is_some() { - tracing::error!("Already subscribed to `candle`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "candle".to_string() })); - } - if !SUPPORTED_INTERVALS.contains(interval.as_str()) { return Err(HyperliquidError::InvalidRequestParameter { method: "subscribe_candle".to_string(), parameter: "interval".to_string(), - reason: format!("Supported intervals include: {:?}", SUPPORTED_INTERVALS) }) + reason: format!("Supported intervals include: {:?}", SUPPORTED_INTERVALS), + }); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "candle", "coin": coin, "interval": interval - }) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.candle = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + { + let mut streams = self.streams.write().await; + let candle = &mut streams.candle; + + if candle.0.is_some() { + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "candle".to_string(), + }, + )); + } + + candle.0 = Some(tx); + candle.1 = Some(coin.clone().into()); + candle.2 = Some(interval.clone()); + } Ok(rx) } @@ -220,25 +231,32 @@ impl<'client> SubscriptionClient<'client> { subscription_config: Option, coin: impl Into + Serialize, ) -> Result> { - if self.streams.read().await.l2book.is_some() { - tracing::error!("Already subscribed to `l2book`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "l2book".to_string() })); - } - - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "l2book", "coin": coin, - }) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.l2book = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + { + let stream_lock = &mut self.streams.write().await.l2book; + if stream_lock.0.is_some() { + tracing::error!("Already subscribed to `l2book`"); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "l2book".to_string(), + }, + )); + } + + stream_lock.0 = Some(tx); + } Ok(rx) } @@ -253,27 +271,35 @@ impl<'client> SubscriptionClient<'client> { pub async fn subscribe_trades( &mut self, subscription_config: Option, - coin: impl Into + Serialize, + coin: impl Into + Serialize + Clone, ) -> Result> { - if self.streams.read().await.trades.is_some() { - tracing::error!("Already subscribed to `trades`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "trades".to_string() })); - } - - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "trades", "coin": coin, - }) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.trades = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + { + let stream_lock = &mut self.streams.write().await.trades; + if stream_lock.0.is_some() { + tracing::error!("Already subscribed to `trades`"); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "trades".to_string(), + }, + )); + } + + stream_lock.0 = Some(tx); + stream_lock.1 = Some(coin.clone().into()); + } Ok(rx) } @@ -285,28 +311,37 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender` - pub async fn subscribe_notifications(&mut self, + pub async fn subscribe_notifications( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.notifications.is_some() { - tracing::error!("Already subscribed to `notifications`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "notifications".to_string() })); - } - - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - + user: impl Into, + ) -> Result> { let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "notification", "user": json!(user.into()) - }) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.notifications = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + { + let stream_lock = &mut self.streams.write().await.notifications; + if stream_lock.0.is_some() { + tracing::error!("Already subscribed to `notifications`"); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "notifications".to_string(), + }, + )); + } + + stream_lock.0 = Some(tx); + } Ok(rx) } @@ -318,28 +353,40 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_webdata3(&mut self, + pub async fn subscribe_webdata3( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.webdata3.is_some() { - tracing::error!("Already subscribed to `webdata3`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "webData3".to_string() })); - } - - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + user: impl Into + Serialize + Clone, + ) -> Result> { + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "webdata3", - "user": json!(user.into()) - }) + "user": json!(user.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.webdata3 = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + { + let (opt_tx, opt_user) = &mut self.streams.write().await.webdata3; + if opt_tx.is_some() { + tracing::error!("Already subscribed to `webdata3`"); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "webData3".to_string(), + }, + )); + } + + *opt_tx = Some(tx); + *opt_user = Some(user); + } Ok(rx) } @@ -351,33 +398,43 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_twap_states(&mut self, + pub async fn subscribe_twap_states( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.twap_states.is_some() { - tracing::error!("Already subscribed to `twapStates`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "twapStates".to_string() })); - } - - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + user: impl Into, + ) -> Result> { + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "twapStates", - "user": json!(user.into()) - }) + "user": json!(user.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.twap_states = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + { + let (opt_tx, opt_user) = &mut self.streams.write().await.twap_states; + if opt_tx.is_some() { + tracing::error!("Already subscribed to `twapStates`"); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "twapStates".to_string(), + }, + )); + } + + *opt_tx = Some(tx); + } Ok(rx) } - /// Subscribes to the [`WsClearinghouseState`] websocket feed. /// /// # Arguments @@ -385,28 +442,38 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_clearinghouse_state(&mut self, + pub async fn subscribe_clearinghouse_state( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.clearinghouse_state.is_some() { + user: impl Into, + ) -> Result> { + if self.streams.read().await.clearinghouse_state.0.is_some() { tracing::error!("Already subscribed to `clearinghouseState"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "clearinghouseState".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "clearinghouseState".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "clearinghouseState", - "user": json!(user.into()) - }) + "user": json!(user.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.clearinghouse_state = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.clearinghouse_state; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } @@ -418,28 +485,38 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_open_orders(&mut self, + pub async fn subscribe_open_orders( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.clearinghouse_state.is_some() { + user: impl Into, + ) -> Result> { + if self.streams.read().await.open_orders.0.is_some() { tracing::error!("Already subscribed to `openOrders"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "openOrders".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "openOrders".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "openOrders", - "user": json!(user.into()) - }) + "user": json!(user.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.open_orders = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.open_orders; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } @@ -451,28 +528,38 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_events(&mut self, + pub async fn subscribe_user_events( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.user_events.is_some() { + user: impl Into, + ) -> Result> { + if self.streams.read().await.user_events.0.is_some() { tracing::error!("Already subscribed to `userEvents`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userEvents".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "userEvents".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "userEvents", - "user": json!(user.into()) - }) + "user": json!(user.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.user_events = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let mut write_lock = &mut self.streams.write().await.user_events; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } @@ -484,28 +571,38 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_fills(&mut self, + pub async fn subscribe_user_fills( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.user_events.is_some() { + user: impl Into, + ) -> Result> { + if self.streams.read().await.user_fills.0.is_some() { tracing::error!("Already subscribed to `userFills`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userFills".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "userFills".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "userFills", - "user": json!(user.into()) - }) + "user": json!(user.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.user_fills = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let mut write_lock = &mut self.streams.write().await.user_fills; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } @@ -517,28 +614,38 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_funding(&mut self, + pub async fn subscribe_user_funding( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.user_events.is_some() { + user: impl Into, + ) -> Result> { + if self.streams.read().await.user_funding.0.is_some() { tracing::error!("Already subscribed to `userFunding`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userFunding".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "userFunding".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "userFunding", - "user": json!(user.into()) - }) + "user": json!(user.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.user_funding = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.user_funding; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } @@ -550,28 +657,45 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_non_funding_ledger_updates(&mut self, + pub async fn subscribe_user_non_funding_ledger_updates( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.user_non_funding_ledger_updates.is_some() { + user: impl Into, + ) -> Result> { + if self + .streams + .read() + .await + .user_non_funding_ledger_updates + .0 + .is_some() + { tracing::error!("Already subscribed to `userNonFundingLedgerUpdates`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userNonFundingLedgerUpdates".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "userNonFundingLedgerUpdates".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "userNonFundingLedgerUpdates", - "user": json!(user.into()) - }) + "user": json!(user.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.user_non_funding_ledger_updates= Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.user_non_funding_ledger_updates; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } @@ -583,28 +707,38 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_active_asset_ctx(&mut self, + pub async fn subscribe_active_asset_ctx( + &mut self, subscription_config: Option, - coin: impl Into) -> Result> { - if self.streams.read().await.active_asset_ctx.is_some() { + coin: impl Into + Clone, + ) -> Result> { + if self.streams.read().await.active_asset_ctx.0.is_some() { tracing::error!("Already subscribed to `activeAssetCtx`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "activeAssetCtx".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "activeAssetCtx".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let coin = coin.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "activeAssetCtx", - "coin": json!(coin.into()) - }) + "coin": json!(coin.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.active_asset_ctx = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.active_asset_ctx; + write_lock.0 = Some(tx); + write_lock.1 = Some(coin); Ok(rx) } @@ -617,30 +751,42 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_active_asset_data(&mut self, + pub async fn subscribe_active_asset_data( + &mut self, subscription_config: Option, user: impl Into, - coin: impl Into) -> Result> { - if self.streams.read().await.active_asset_data.is_some() { + coin: impl Into, + ) -> Result> { + if self.streams.read().await.active_asset_data.0.is_some() { tracing::error!("Already subscribed to `activeAssetData`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "activeAssetData".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "activeAssetData".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); + let coin = coin.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "activeAssetData", - "user": json!(user.into()), - "coin": json!(coin.into()) - }) + "user": json!(user.clone()), + "coin": json!(coin.clone()) + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.active_asset_data = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.active_asset_data; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); + write_lock.2 = Some(coin); Ok(rx) } @@ -653,28 +799,38 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_twap_slice_fills(&mut self, + pub async fn subscribe_user_twap_slice_fills( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.user_twap_slice_fills.is_some() { + user: impl Into, + ) -> Result> { + if self.streams.read().await.user_twap_slice_fills.0.is_some() { tracing::error!("Already subscribed to `userTwapSliceFills`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userTwapSliceFills".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "userTwapSliceFills".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "userTwapSliceFills", - "user": json!(user.into()), - }) + "user": json!(user.clone()), + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.user_twap_slice_fills = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.user_twap_slice_fills; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } @@ -687,28 +843,38 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_twap_history(&mut self, + pub async fn subscribe_user_twap_history( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.user_twap_history.is_some() { + user: impl Into, + ) -> Result> { + if self.streams.read().await.user_twap_history.0.is_some() { tracing::error!("Already subscribed to `userTwapHistory`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "userTwapHistory".to_string() })); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "userTwapHistory".to_string(), + }, + )); } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "userTwapHistory", - "user": json!(user.into()), - }) + "user": json!(user.clone()), + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.user_twap_history = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.user_twap_history; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } @@ -721,29 +887,45 @@ impl<'client> SubscriptionClient<'client> { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_bbo(&mut self, + pub async fn subscribe_bbo( + &mut self, subscription_config: Option, - user: impl Into) -> Result> { - if self.streams.read().await.bbo.is_some() { - tracing::error!("Already subscribed to `bbo`"); - return Err(HyperliquidError::SubscriptionError(SubscriptionError::SubscriptionExist { method: "bbo".to_string() })); + user: impl Into, + ) -> Result> { + { + if self.streams.read().await.bbo.0.is_some() { + tracing::error!("Already subscribed to `bbo`"); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "bbo".to_string(), + }, + )); + } } - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + let user = user.into(); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "bbo", - "user": json!(user.into()), - }) + "user": json!(user.clone()), + }), }; self.send_and_flush(subscription_message).await?; - self.streams.write().await.bbo = Some(tx); + let capacity = subscription_config.unwrap_or_default().channel_capacity; + let (tx, rx) = Self::subscription_channel::(Some(capacity)); + + let write_lock = &mut self.streams.write().await.bbo; + write_lock.0 = Some(tx); + write_lock.1 = Some(user); Ok(rx) } + + pub fn unsubscribe(&self, key: SubscriptionKey) -> Result<()> { + Ok(()) + } } diff --git a/src/client/builder.rs b/src/client/builder.rs index cda5935..be584f7 100644 --- a/src/client/builder.rs +++ b/src/client/builder.rs @@ -6,7 +6,7 @@ pub struct HyperliquidClientBuilder { base_url: Option, network: Option, wallet: Option, - ws_endpoint: Option + ws_endpoint: Option, } impl Default for HyperliquidClientBuilder { @@ -23,7 +23,7 @@ impl HyperliquidClientBuilder { base_url: None, network: None, wallet: None, - ws_endpoint: None + ws_endpoint: None, } } @@ -55,13 +55,15 @@ impl HyperliquidClientBuilder { pub fn with_subscriptions(&mut self) -> &mut Self { match &self.network { - Some(network) => { - match network { - NetworkType::Mainnet => self.ws_endpoint = Some("wss://api.hyperliquid.xyz/ws".to_string()), - NetworkType::Testnet => self.ws_endpoint = Some("wss://api.hyperliquid-testnet.xyz/ws".to_string()) + Some(network) => match network { + NetworkType::Mainnet => { + self.ws_endpoint = Some("wss://api.hyperliquid.xyz/ws".to_string()) } - } - None => self.ws_endpoint = Some("wss://api.hyperliquid-testnet.xyz/ws".to_string()) + NetworkType::Testnet => { + self.ws_endpoint = Some("wss://api.hyperliquid-testnet.xyz/ws".to_string()) + } + }, + None => self.ws_endpoint = Some("wss://api.hyperliquid-testnet.xyz/ws".to_string()), } self @@ -79,6 +81,11 @@ impl HyperliquidClientBuilder { // Default to Testnet let network = self.network.clone().unwrap_or(NetworkType::Testnet); - HyperliquidClient::new(network, base_url, self.ws_endpoint.clone(), self.wallet.clone()) + HyperliquidClient::new( + network, + base_url, + self.ws_endpoint.clone(), + self.wallet.clone(), + ) } } diff --git a/src/client/client.rs b/src/client/client.rs index 75bcf5c..d7719f3 100644 --- a/src/client/client.rs +++ b/src/client/client.rs @@ -18,7 +18,7 @@ pub struct Inner { base_url: String, wallet: Option, network: NetworkType, - ws_endpoint: Option + ws_endpoint: Option, } impl Inner { @@ -27,7 +27,6 @@ impl Inner { base_url: String, ws_endpoint: Option, wallet: Option, - ) -> Result { let http_client = ClientBuilder::new().build()?; @@ -82,7 +81,13 @@ impl HyperliquidClient { } pub async fn subscriptions(&self) -> Result> { - let endpoint = self.inner.ws_endpoint.clone().ok_or(HyperliquidError::MissingConfiguration { parameter: "ws_endpoint".to_string() })?; + let endpoint = + self.inner + .ws_endpoint + .clone() + .ok_or(HyperliquidError::MissingConfiguration { + parameter: "ws_endpoint".to_string(), + })?; SubscriptionClient::new(endpoint, self).await } diff --git a/src/error.rs b/src/error.rs index 808c05a..730ade6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,7 +5,7 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum SubscriptionError { #[error("Subscription already exist for method {method}")] - SubscriptionExist { method: String } + SubscriptionExist { method: String }, } #[derive(Error, Debug)] diff --git a/src/example_helpers.rs b/src/example_helpers.rs index cadadf3..0058396 100644 --- a/src/example_helpers.rs +++ b/src/example_helpers.rs @@ -5,12 +5,20 @@ use std::env; pub fn testnet_client() -> Result> { let signer = load_signer(); - Ok(HyperliquidClient::builder().testnet().with_wallet(signer).with_subscriptions().build()?) + Ok(HyperliquidClient::builder() + .testnet() + .with_wallet(signer) + .with_subscriptions() + .build()?) } pub fn mainnet_client() -> Result> { let signer = load_signer(); - Ok(HyperliquidClient::builder().mainnet().with_wallet(signer).with_subscriptions().build()?) + Ok(HyperliquidClient::builder() + .mainnet() + .with_wallet(signer) + .with_subscriptions() + .build()?) } #[allow(clippy::must_use_candidate)] diff --git a/src/types/ws.rs b/src/types/ws.rs index 9881988..2bf49ca 100644 --- a/src/types/ws.rs +++ b/src/types/ws.rs @@ -650,9 +650,28 @@ pub struct SubscriptionConfirmation { } #[derive(Serialize, Deserialize, Clone, Debug)] -pub enum WsAssetCtx{ +pub enum WsAssetCtx { Ctx(WsActiveAssetCtx), - Data(WsActiveSpotAssetCtx) + Data(WsActiveSpotAssetCtx), +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum SubscriptionKey { + AllMids, + Candle, + Trades, + L2Book, + Notification, + WebData3, + TwapStates, + OpenOrders, + UserEvents, + UserNonFundingLedgerUpdate, + ActiveAssetCtx, + ActiveAssetData, + UserTwapSliceFills, + UserTwapHistory, + Bbo, } #[derive(Serialize, Deserialize, Debug)] @@ -665,6 +684,12 @@ pub enum SubscriptionResponse { AllMids(WsAllMids), + Candle(WsCandle), + + Trades(WsTrade), + + L2Book(WsBook), + Notification(WsNotification), WebData3(WsWebData3), @@ -677,5 +702,13 @@ pub enum SubscriptionResponse { UserNonFundingLedgerUpdate(WsUserNonFundingLedgerUpdate), - AssetCtx(WsAssetCtx) + ActiveAssetCtx(WsAssetCtx), + + ActiveAssetData(WsActiveAssetData), + + UserTwapSliceFills(WsUserTwapSliceFills), + + UserTwapHistory(WsUserTwapHistory), + + Bbo(WsBbo), } From 7b3997c942dfc90c539887b0a160480ae1e2d933 Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Mon, 15 Dec 2025 18:30:41 -0500 Subject: [PATCH 4/9] Resolve clippy warning,errors --- Cargo.toml | 3 +- examples/advanced_order.rs | 2 +- examples/subscriptions.rs | 4 +- src/api/subscription/sender.rs | 13 +- src/api/subscription/ws.rs | 216 ++++++++++++++++++++++++++++----- src/client/client.rs | 17 ++- src/error.rs | 4 + src/types/ws.rs | 55 ++++++--- 8 files changed, 248 insertions(+), 66 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 011cdc3..aa2f466 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ missing_docs = "allow" [lints.clippy] unwrap_used = "deny" -expect_used = "deny" + panic = "deny" unreachable = "deny" arithmetic_side_effects = "deny" @@ -69,6 +69,7 @@ cast_possible_truncation = "deny" cast_precision_loss = "deny" as_conversions = "warn" +expect_used = "allow" map_unwrap_or = "allow" min_ident_chars = "allow" question_mark_used = "allow" diff --git a/examples/advanced_order.rs b/examples/advanced_order.rs index d8829fe..5da63c2 100644 --- a/examples/advanced_order.rs +++ b/examples/advanced_order.rs @@ -90,7 +90,7 @@ async fn main() -> Result<(), Box> { t: OrderType::Trigger(TriggerOrder { is_market: true, trigger_px: take_profit_trigger.clone(), - tpsl: Tpsl::Tpt, + tpsl: Tpsl::Tp, }), c: None, }; diff --git a/examples/subscriptions.rs b/examples/subscriptions.rs index b96b4ce..87b6330 100644 --- a/examples/subscriptions.rs +++ b/examples/subscriptions.rs @@ -9,11 +9,11 @@ async fn main() -> Result<(), Box> { .subscriptions() .await .unwrap() - .subscribe_all_mids(None) + .subscribe_all_mids(None, None) .await .unwrap(); - while let Some(msg) = rx.recv().await { + while let Ok(msg) = rx.recv().await { tracing::info!("Received new message from 'allMids' subscription."); tracing::info!("{:?}", msg); } diff --git a/src/api/subscription/sender.rs b/src/api/subscription/sender.rs index 33045cf..1f21603 100644 --- a/src/api/subscription/sender.rs +++ b/src/api/subscription/sender.rs @@ -11,8 +11,13 @@ use tokio::sync::broadcast::Sender; pub struct StreamSenders { pub(crate) all_mids: (Option>, Option), pub(crate) candle: (Option>, Option, Option), - pub(crate) trades: (Option>, Option), - pub(crate) l2book: (Option>, Option), + pub(crate) trades: ( + Option>, + Option, + Option, + Option, + ), + pub(crate) l2book: (Option>, Option, Option, Option), pub(crate) notifications: (Option>, Option), pub(crate) webdata3: (Option>, Option), pub(crate) twap_states: (Option>, Option), @@ -39,8 +44,8 @@ impl StreamSenders { Self { all_mids: (None, None), candle: (None, None, None), - trades: (None, None), - l2book: (None, None), + trades: (None, None, None, None), + l2book: (None, None, None, None), notifications: (None, None), webdata3: (None, None), twap_states: (None, None), diff --git a/src/api/subscription/ws.rs b/src/api/subscription/ws.rs index ca619af..0a5a8ab 100644 --- a/src/api/subscription/ws.rs +++ b/src/api/subscription/ws.rs @@ -1,4 +1,3 @@ -#![allow(dead_code, unused_variables)] use crate::api::subscription::sender::StreamSenders; use crate::api::SUPPORTED_INTERVALS; use crate::client::HyperliquidClient; @@ -35,19 +34,19 @@ impl Default for SubscriptionConfig { } } -/// A client providing access to Hyperliquid Subscriptions API. -pub struct SubscriptionClient<'client> { +/// A WS client providing access to Hyperliquid Subscriptions API. +pub struct SubscriptionClient { write_stream: SplitSink< WebSocketStream>, Message, >, streams: Arc>, - client: &'client HyperliquidClient, } -impl<'client> SubscriptionClient<'client> { - pub async fn new(ws_endpoint: String, client: &'client HyperliquidClient) -> Result { - let (ws_stream, _response) = connect_async(&ws_endpoint).await?; +impl SubscriptionClient { + pub async fn new(client: &HyperliquidClient) -> Result { + let endpoint = client.ws_endpoint().ok_or(HyperliquidError::MissingConfiguration { parameter: "ws_endpoint".to_string() })?; + let (ws_stream, _response) = connect_async(endpoint).await?; let (write_stream, read_stream) = ws_stream.split(); let senders = Arc::new(RwLock::new(StreamSenders::new())); @@ -57,7 +56,6 @@ impl<'client> SubscriptionClient<'client> { Ok(Self { write_stream, streams: senders, - client, }) } @@ -95,7 +93,7 @@ impl<'client> SubscriptionClient<'client> { tracing::error!("{:?}", err); } SubscriptionResponse::AllMids(mids) => { - if let (Some(tx), Some(dex)) = &senders.read().await.all_mids { + if let (Some(tx), Some(_)) = &senders.read().await.all_mids { if let Err(e) = tx.send(mids) { tracing::error!("{:?}", e); } @@ -107,6 +105,155 @@ impl<'client> SubscriptionClient<'client> { }) } + pub async fn unsubscribe(&mut self, key: &SubscriptionKey) -> Result<()> { + let subscription = { + let streams = self.streams.read().await; + Self::build_unsubscribe_payload(&streams, key)? + }; + + let msg = SubscriptionConfirmation { + method: "unsubscribe".into(), + subscription: serde_json::Value::Object(subscription), + }; + + self.send_and_flush(msg).await?; + + { + let mut streams = self.streams.write().await; + Self::clear_subscription(&mut streams, key); + } + + Ok(()) + } + + fn build_unsubscribe_payload( + streams: &StreamSenders, + key: &SubscriptionKey, + ) -> Result> { + use serde_json::{Map, Value}; + + let mut sub = Map::new(); + + match key { + SubscriptionKey::AllMids => { + let (_, dex) = &streams.all_mids; + Self::ensure_present(&streams.all_mids.0, key)?; + sub.insert("type".into(), Value::String("all_mids".into())); + if let Some(d) = dex { + sub.insert("dex".into(), Value::String(d.clone())); + } + } + + SubscriptionKey::Candle => { + let (_, coin, interval) = &streams.candle; + Self::ensure_present(&streams.candle.0, key)?; + sub.insert("type".into(), Value::String("candle".into())); + sub.insert("coin".into(), Value::String(Self::req(coin, key)?)); + sub.insert("interval".into(), Value::String(Self::req(interval, key)?)); + } + + SubscriptionKey::Trades => { + let (_, coin, _, _) = &streams.trades; + Self::ensure_present(&streams.trades.0, key)?; + sub.insert("coin".into(), Value::String(Self::req(coin, key)?)); + } + + SubscriptionKey::L2Book => { + let (_, coin, _, _) = &streams.l2book; + Self::ensure_present(&streams.l2book.0, key)?; + sub.insert("coin".into(), Value::String(Self::req(coin, key)?)); + } + + SubscriptionKey::Notification => { + Self::user_only(&mut sub, &streams.notifications, key)?; + } + SubscriptionKey::WebData3 => Self::user_only(&mut sub, &streams.webdata3, key)?, + SubscriptionKey::TwapStates => Self::user_only(&mut sub, &streams.twap_states, key)?, + SubscriptionKey::OpenOrders => Self::user_only(&mut sub, &streams.open_orders, key)?, + SubscriptionKey::UserEvents => Self::user_only(&mut sub, &streams.user_events, key)?, + SubscriptionKey::UserNonFundingLedgerUpdate => { + Self::user_only(&mut sub, &streams.user_non_funding_ledger_updates, key)?; + } + + SubscriptionKey::ActiveAssetCtx => { + let (_, coin) = &streams.active_asset_ctx; + Self::ensure_present(&streams.active_asset_ctx.0, key)?; + sub.insert("coin".into(), Value::String(Self::req(coin, key)?)); + } + + SubscriptionKey::ActiveAssetData => { + let (_, user, coin) = &streams.active_asset_data; + Self::ensure_present(&streams.active_asset_data.0, key)?; + sub.insert("user".into(), Value::String(Self::req(user, key)?)); + sub.insert("coin".into(), Value::String(Self::req(coin, key)?)); + } + + SubscriptionKey::UserTwapSliceFills => { + Self::user_only(&mut sub, &streams.user_twap_slice_fills, key)?; + } + + SubscriptionKey::UserTwapHistory => { + Self::user_only(&mut sub, &streams.user_twap_history, key)?; + } + + SubscriptionKey::Bbo => Self::user_only(&mut sub, &streams.bbo, key)?, + } + + Ok(sub) + } + + fn ensure_present(sender: Option<&Sender>, key: &SubscriptionKey) -> Result<()> { + if sender.is_none() { + Err(Self::missing(key)) + } else { + Ok(()) + } + } + + fn req(v: Option<&String>, key: &SubscriptionKey) -> Result { + v.clone().ok_or_else(|| Self::missing(key)) + } + + fn user_only( + sub: &mut serde_json::Map, + entry: &(Option>, Option), + key: &SubscriptionKey, + ) -> Result<()> { + Self::ensure_present(&entry.0, key)?; + sub.insert( + "user".into(), + serde_json::Value::String(Self::req(&entry.1, key)?), + ); + Ok(()) + } + + fn missing(key: &SubscriptionKey) -> HyperliquidError { + HyperliquidError::SubscriptionError(SubscriptionError::MissingSubscription(key.clone())) + } + fn clear_subscription(streams: &mut StreamSenders, key: &SubscriptionKey) { + match key { + SubscriptionKey::AllMids => streams.all_mids = Default::default(), + SubscriptionKey::Candle => streams.candle = Default::default(), + SubscriptionKey::Trades => streams.trades = Default::default(), + SubscriptionKey::L2Book => streams.l2book = Default::default(), + SubscriptionKey::Notification => streams.notifications = Default::default(), + SubscriptionKey::WebData3 => streams.webdata3 = Default::default(), + SubscriptionKey::TwapStates => streams.twap_states = Default::default(), + SubscriptionKey::OpenOrders => streams.open_orders = Default::default(), + SubscriptionKey::UserEvents => streams.user_events = Default::default(), + SubscriptionKey::UserNonFundingLedgerUpdate => { + streams.user_non_funding_ledger_updates = Default::default(); + } + SubscriptionKey::ActiveAssetCtx => streams.active_asset_ctx = Default::default(), + SubscriptionKey::ActiveAssetData => streams.active_asset_data = Default::default(), + SubscriptionKey::UserTwapSliceFills => { + streams.user_twap_slice_fills = Default::default(); + } + SubscriptionKey::UserTwapHistory => streams.user_twap_history = Default::default(), + SubscriptionKey::Bbo => streams.bbo = Default::default(), + } + } + /// Subscribes to the [`WsAllMids`] websocket feed. /// /// # Arguments @@ -132,7 +279,7 @@ impl<'client> SubscriptionClient<'client> { if let Some(obj) = dex_obj.as_object_mut() { let val = serde_json::Value::String(dex_param); - obj.insert("dex".to_string(), val.clone()); + obj.insert("dex".to_string(), val); } } } @@ -144,7 +291,6 @@ impl<'client> SubscriptionClient<'client> { { let write_lock = &mut self.streams.write().await.all_mids; - // let (mut opt_tx, mut opt_dex) = write_lock; if write_lock.0.is_some() { tracing::error!("Already subscribed to `AllMids`"); @@ -200,8 +346,7 @@ impl<'client> SubscriptionClient<'client> { let (tx, rx) = Self::subscription_channel::(Some(capacity)); { - let mut streams = self.streams.write().await; - let candle = &mut streams.candle; + let mut candle = self.streams.write().await.candle; if candle.0.is_some() { return Err(HyperliquidError::SubscriptionError( @@ -230,15 +375,28 @@ impl<'client> SubscriptionClient<'client> { &mut self, subscription_config: Option, coin: impl Into + Serialize, + n_sig_figs: Option, + mantissa: Option, ) -> Result> { - let subscription_message = SubscriptionConfirmation { + let coin = coin.into(); + + let mut subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ "type": "l2book", - "coin": coin, + "coin": coin.clone(), }), }; + let subscription_data = subscription_message.subscription.as_object_mut().expect("subscription object to be valid"); + if let Some(sig_figs) = n_sig_figs { + subscription_data.insert("nSigFigs".to_string(), serde_json::Value::Number(sig_figs.into())); + } + + if let Some(mantissa) = mantissa { + subscription_data.insert("mantissa".to_string(), serde_json::Value::Number(mantissa.into())); + } + self.send_and_flush(subscription_message).await?; let capacity = subscription_config.unwrap_or_default().channel_capacity; @@ -256,6 +414,9 @@ impl<'client> SubscriptionClient<'client> { } stream_lock.0 = Some(tx); + stream_lock.1 = Some(coin); + stream_lock.2 = n_sig_figs; + stream_lock.3 = mantissa; } Ok(rx) @@ -430,6 +591,7 @@ impl<'client> SubscriptionClient<'client> { } *opt_tx = Some(tx); + *opt_user = Some(user); } Ok(rx) @@ -557,7 +719,7 @@ impl<'client> SubscriptionClient<'client> { let capacity = subscription_config.unwrap_or_default().channel_capacity; let (tx, rx) = Self::subscription_channel::(Some(capacity)); - let mut write_lock = &mut self.streams.write().await.user_events; + let write_lock = &mut self.streams.write().await.user_events; write_lock.0 = Some(tx); write_lock.1 = Some(user); @@ -600,7 +762,7 @@ impl<'client> SubscriptionClient<'client> { let capacity = subscription_config.unwrap_or_default().channel_capacity; let (tx, rx) = Self::subscription_channel::(Some(capacity)); - let mut write_lock = &mut self.streams.write().await.user_fills; + let write_lock = &mut self.streams.write().await.user_fills; write_lock.0 = Some(tx); write_lock.1 = Some(user); @@ -892,15 +1054,13 @@ impl<'client> SubscriptionClient<'client> { subscription_config: Option, user: impl Into, ) -> Result> { - { - if self.streams.read().await.bbo.0.is_some() { - tracing::error!("Already subscribed to `bbo`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "bbo".to_string(), - }, - )); - } + if self.streams.read().await.bbo.0.is_some() { + tracing::error!("Already subscribed to `bbo`"); + return Err(HyperliquidError::SubscriptionError( + SubscriptionError::SubscriptionExist { + method: "bbo".to_string(), + }, + )); } let user = user.into(); @@ -924,8 +1084,4 @@ impl<'client> SubscriptionClient<'client> { Ok(rx) } - - pub fn unsubscribe(&self, key: SubscriptionKey) -> Result<()> { - Ok(()) - } } diff --git a/src/client/client.rs b/src/client/client.rs index d7719f3..5566b35 100644 --- a/src/client/client.rs +++ b/src/client/client.rs @@ -1,7 +1,7 @@ use crate::{ api::{exchange::ExchangeApi, info::InfoApi, SubscriptionClient}, client::HyperliquidClientBuilder, - error::{HyperliquidError, Result}, + error::Result, types::chain::NetworkType, }; use alloy::signers::local::PrivateKeySigner; @@ -64,6 +64,10 @@ impl HyperliquidClient { &self.inner.base_url } + pub fn ws_endpoint(&self) -> Option<&String> { + self.inner.ws_endpoint.as_ref() + } + pub fn signer(&self) -> Option<&PrivateKeySigner> { self.inner.wallet.as_ref() } @@ -80,15 +84,8 @@ impl HyperliquidClient { ExchangeApi::new(self) } - pub async fn subscriptions(&self) -> Result> { - let endpoint = - self.inner - .ws_endpoint - .clone() - .ok_or(HyperliquidError::MissingConfiguration { - parameter: "ws_endpoint".to_string(), - })?; - SubscriptionClient::new(endpoint, self).await + pub async fn subscriptions(&self) -> Result { + SubscriptionClient::new(self).await } pub fn is_mainnet(&self) -> bool { diff --git a/src/error.rs b/src/error.rs index 730ade6..0c48935 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,10 +2,14 @@ use alloy::primitives::SignatureError; use std::{result::Result as StdResult, string::FromUtf8Error}; use thiserror::Error; +use crate::types::ws::SubscriptionKey; + #[derive(Error, Debug)] pub enum SubscriptionError { #[error("Subscription already exist for method {method}")] SubscriptionExist { method: String }, + #[error("Caller not subscribed to the {0} feed.")] + MissingSubscription(SubscriptionKey), } #[derive(Error, Debug)] diff --git a/src/types/ws.rs b/src/types/ws.rs index 2bf49ca..b2a0e7d 100644 --- a/src/types/ws.rs +++ b/src/types/ws.rs @@ -1,6 +1,5 @@ -#![allow(dead_code)] - -/// Request and response types for WebSocket subscriptions and streaming data. +/// Request and response types for WebSocket subscriptions +/// and streaming data. use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -655,7 +654,11 @@ pub enum WsAssetCtx { Data(WsActiveSpotAssetCtx), } -#[derive(Serialize, Deserialize, Debug)] +/// Identifies a concrete websocket subscription type supported by the feed. +/// +/// Each variant corresponds to a distinct server-side stream and determines +/// both the subscription parameters and the shape of messages received. +#[derive(Serialize, Deserialize, Clone, Debug)] pub enum SubscriptionKey { AllMids, Candle, @@ -674,41 +677,57 @@ pub enum SubscriptionKey { Bbo, } +impl std::fmt::Display for SubscriptionKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let _ = match *self { + Self::AllMids => f.write_str("allMids"), + Self::Candle => f.write_str("candle"), + Self::Trades => f.write_str("trades"), + Self::L2Book => f.write_str("l2Book"), + Self::Notification => f.write_str("notification"), + Self::WebData3 => f.write_str("webData3"), + Self::TwapStates => f.write_str("twapStates"), + Self::OpenOrders => f.write_str("openOrders"), + Self::UserEvents => f.write_str("userEvents"), + Self::UserNonFundingLedgerUpdate => f.write_str("userNonFundingLedgerUpdate"), + Self::ActiveAssetCtx => f.write_str("activeAssetCtx"), + Self::ActiveAssetData => f.write_str("activeAssetData"), + Self::UserTwapSliceFills => f.write_str("userTwapSliceFills"), + Self::UserTwapHistory => f.write_str("userTwapHistory"), + Self::Bbo => f.write_str("bbo"), + }; + + Ok(()) + } +} + +/// Messages delivered over websocket subscription channels. +/// +/// The `channel` field selects the subscription stream, while `data` contains +/// the stream-specific payload deserialized into a strongly typed variant. #[derive(Serialize, Deserialize, Debug)] #[serde(tag = "channel", content = "data", rename_all = "camelCase")] pub enum SubscriptionResponse { + /// Server-side error emitted over the websocket connection. #[serde(rename = "error")] Error(String), + /// Acknowledgement or status response for subscribe / unsubscribe requests. SubscriptionResponse(SubscriptionConfirmation), AllMids(WsAllMids), - Candle(WsCandle), - Trades(WsTrade), - L2Book(WsBook), - Notification(WsNotification), - WebData3(WsWebData3), - TwapStates(WsTwapStates), - OpenOrders(WsOpenOrders), - UserEvents(WsUserEvent), - UserNonFundingLedgerUpdate(WsUserNonFundingLedgerUpdate), - ActiveAssetCtx(WsAssetCtx), - ActiveAssetData(WsActiveAssetData), - UserTwapSliceFills(WsUserTwapSliceFills), - UserTwapHistory(WsUserTwapHistory), - Bbo(WsBbo), } From fe93802c2c726534dbce9b5ef4cc0ece80596687 Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Fri, 19 Dec 2025 01:52:00 -0500 Subject: [PATCH 5/9] Adds async write and read task to subscription API. Replaces direct write to write_stream with channel based communication. Adds heartbeat subscription message --- Cargo.toml | 3 +- examples/subscriptions.rs | 60 +- src/api/mod.rs | 2 +- src/api/subscription/mod.rs | 2 +- src/api/subscription/sender.rs | 8 +- src/api/subscription/ws.rs | 1209 +++++++++++++++----------------- src/client/client.rs | 2 +- src/error.rs | 17 +- src/types/serialize.rs | 29 + src/types/ws.rs | 259 ++++--- 10 files changed, 859 insertions(+), 732 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cb21acb..5a23f37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ futures-util = { version = "0.3.31", features = ["sink"] } once_cell = "1.19" reqwest = { version = "=0.12.23", features = ["json", "rustls-tls"] } rmp-serde = "1.3.0" -rust_decimal = { version = "1.35", features = ["serde", "serde-float"] } +rust_decimal = { version = "1.35", features = ["serde", "serde-float", "serde-str", "serde-with-str"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } thiserror = "1.0" @@ -91,6 +91,7 @@ uninlined_format_args = "allow" similar_names = "allow" redundant_closure_for_method_calls = "allow" inconsistent_struct_constructor = "allow" +match_same_arms = "allow" pedantic = { level = "warn", priority = -1 } restriction = { level = "allow", priority = -1 } diff --git a/examples/subscriptions.rs b/examples/subscriptions.rs index 7ee1a5b..b8dbd6a 100644 --- a/examples/subscriptions.rs +++ b/examples/subscriptions.rs @@ -1,19 +1,59 @@ -use rhyperliquid::{example_helpers::testnet_client, init_tracing::init_tracing}; +use rhyperliquid::{ + example_helpers::{testnet_client, user}, + init_tracing::init_tracing, +}; #[tokio::main] async fn main() -> Result<(), Box> { init_tracing(); let client = testnet_client()?; - if let Ok(mut rx) = client - .subscriptions() - .await? - .subscribe_all_mids(None, None) - .await - { - while let Ok(msg) = rx.recv().await { - tracing::info!("Received new message from 'allMids' subscription."); - tracing::info!("{:?}", msg); + let mut subs = client.subscriptions().await?; + let user = user(); + + subs.subscribe_all_mids(None).await?; + subs.subscribe_candle("BTC", "5m".to_string()).await?; + subs.subscribe_l2_book("BTC", None, None).await?; + subs.subscribe_trades("BTC").await?; + subs.subscribe_notifications(user.clone()).await?; + subs.subscribe_webdata3(user.clone()).await?; + subs.subscribe_twap_states(user.clone()).await?; + subs.subscribe_clearinghouse_state(user.clone()).await?; + subs.subscribe_open_orders(user.clone()).await?; + subs.subscribe_user_events(user.clone()).await?; + subs.subscribe_user_fills(user.clone()).await?; + subs.subscribe_user_funding(user.clone()).await?; + subs.subscribe_user_non_funding_ledger_updates(user.clone()).await?; + subs.subscribe_active_asset_ctx("BTC").await?; + subs.subscribe_active_asset_data(user.clone(), "BTC").await?; + subs.subscribe_user_twap_slice_fills(user.clone()).await?; + subs.subscribe_user_twap_history(user.clone()).await?; + subs.subscribe_bbo("BTC").await?; + + // Match and receive subscription messages + while let Ok(msg) = subs.events.recv().await { + match msg { + rhyperliquid::types::ws::SubscriptionResponse::Error(e)=>{tracing::info!("Error: {:?}",e);} + rhyperliquid::types::ws::SubscriptionResponse::SubscriptionResponse(subscription_confirmation,)=>{tracing::info!("SubscriptionResponse: {:?}",subscription_confirmation);} + rhyperliquid::types::ws::SubscriptionResponse::AllMids(ws_all_mids)=>{tracing::info!("AllMids: {:?}",ws_all_mids);} + rhyperliquid::types::ws::SubscriptionResponse::Candle(ws_candle)=>{tracing::info!("Candle: {:?}",ws_candle);} + rhyperliquid::types::ws::SubscriptionResponse::Trades(ws_trade)=>{tracing::info!("Trades: {:?}",ws_trade);} + rhyperliquid::types::ws::SubscriptionResponse::L2Book(ws_book)=>{tracing::info!("L2Book: {:?}",ws_book);} + rhyperliquid::types::ws::SubscriptionResponse::Notification(ws_notification)=>{tracing::info!("Notification: {:?}",ws_notification);} + rhyperliquid::types::ws::SubscriptionResponse::WebData3(ws_web_data3)=>{tracing::info!("WebData3: {:?}",ws_web_data3);} + rhyperliquid::types::ws::SubscriptionResponse::TwapStates(ws_twap_states)=>{tracing::info!("TwapStates: {:?}",ws_twap_states);} + rhyperliquid::types::ws::SubscriptionResponse::OpenOrders(ws_open_orders)=>{tracing::info!("OpenOrders: {:?}",ws_open_orders);} + rhyperliquid::types::ws::SubscriptionResponse::UserEvents(ws_user_event)=>{tracing::info!("UserEvents: {:?}",ws_user_event);} + rhyperliquid::types::ws::SubscriptionResponse::UserNonFundingLedgerUpdates(ws_user_non_funding_ledger_update,)=>{tracing::info!("UserNonFundingLedgerUpdate: {:?}",ws_user_non_funding_ledger_update);} + rhyperliquid::types::ws::SubscriptionResponse::ActiveAssetCtx(ws_asset_ctx)=>{tracing::info!("ActiveAssetCtx: {:?}",ws_asset_ctx);} + rhyperliquid::types::ws::SubscriptionResponse::ActiveAssetData(ws_active_asset_data,)=>{tracing::info!("ActiveAssetData: {:?}",ws_active_asset_data);} + rhyperliquid::types::ws::SubscriptionResponse::UserTwapSliceFills(ws_user_twap_slice_fills,)=>{tracing::info!("UserTwapSliceFills: {:?}",ws_user_twap_slice_fills);} + rhyperliquid::types::ws::SubscriptionResponse::UserTwapHistory(ws_user_twap_history,)=>{tracing::info!("UserTwapHistory: {:?}",ws_user_twap_history);} + rhyperliquid::types::ws::SubscriptionResponse::Bbo(ws_bbo)=>{tracing::info!("Bbo: {:?}",ws_bbo);} + rhyperliquid::types::ws::SubscriptionResponse::Pong=>tracing::info!("Pong"), + rhyperliquid::types::ws::SubscriptionResponse::ClearinghouseState(ws_clearinghouse_state,)=>{tracing::info!("ClearinghouseState: {:?}",ws_clearinghouse_state);} + rhyperliquid::types::ws::SubscriptionResponse::UserFills(ws_user_fills)=>{tracing::info!("User Fills: {:?}",ws_user_fills);} + rhyperliquid::types::ws::SubscriptionResponse::UserFundings(ws_user_fundings) => {tracing::info!("User Fundings: {:?}",ws_user_fundings);} } } diff --git a/src/api/mod.rs b/src/api/mod.rs index 02f495f..eed3458 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,4 +4,4 @@ pub mod request_util; pub mod response; mod subscription; -pub use subscription::{SubscriptionClient, SubscriptionConfig}; +pub use subscription::{StreamMessage, SubscriptionClient, SubscriptionConfig}; diff --git a/src/api/subscription/mod.rs b/src/api/subscription/mod.rs index 13e1984..de20205 100644 --- a/src/api/subscription/mod.rs +++ b/src/api/subscription/mod.rs @@ -1,4 +1,4 @@ mod sender; mod ws; -pub use ws::{SubscriptionClient, SubscriptionConfig}; +pub use ws::{StreamMessage, SubscriptionClient, SubscriptionConfig}; diff --git a/src/api/subscription/sender.rs b/src/api/subscription/sender.rs index a93465f..aa3ac81 100644 --- a/src/api/subscription/sender.rs +++ b/src/api/subscription/sender.rs @@ -1,6 +1,6 @@ use crate::types::ws::{ - WsActiveAssetData, WsAllMids, WsAssetCtx, WsBbo, WsBook, WsCandle, WsClearinghouseState, - WsNotification, WsOpenOrders, WsTrade, WsTwapStates, WsUserEvent, WsUserFills, WsUserFunding, + WsActiveAssetData, WsAllMids, WsActiveAssetCtx, WsBbo, WsBook, WsCandle, WsClearinghouseState, + WsNotification, WsOpenOrders, WsTrade, WsTwapStates, WsUserEvent, WsUserFills, WsUserFundings, WsUserNonFundingLedgerUpdate, WsUserTwapHistory, WsUserTwapSliceFills, WsWebData3, }; use tokio::sync::broadcast::Sender; @@ -30,10 +30,10 @@ pub struct StreamSenders { pub(crate) open_orders: (Option>, Option), pub(crate) user_events: (Option>, Option), pub(crate) user_fills: (Option>, Option), - pub(crate) user_funding: (Option>, Option), + pub(crate) user_funding: (Option>, Option), pub(crate) user_non_funding_ledger_updates: (Option>, Option), - pub(crate) active_asset_ctx: (Option>, Option), + pub(crate) active_asset_ctx: (Option>, Option), pub(crate) active_asset_data: ( Option>, Option, diff --git a/src/api/subscription/ws.rs b/src/api/subscription/ws.rs index 00c5695..0464940 100644 --- a/src/api/subscription/ws.rs +++ b/src/api/subscription/ws.rs @@ -1,25 +1,32 @@ use crate::api::request_util::SUPPORTED_INTERVALS; -use crate::api::subscription::sender::StreamSenders; use crate::client::HyperliquidClient; -use crate::error::{HyperliquidError, Result, SubscriptionError}; -use crate::types::ws::{ - SubscriptionConfirmation, WsActiveAssetData, WsAllMids, WsAssetCtx, WsBbo, WsBook, WsCandle, - WsClearinghouseState, WsNotification, WsOpenOrders, WsTrade, WsTwapStates, WsUserEvent, - WsUserFills, WsUserFunding, WsUserNonFundingLedgerUpdate, WsUserTwapHistory, - WsUserTwapSliceFills, WsWebData3, -}; -use crate::types::ws::{SubscriptionKey, SubscriptionResponse}; +use crate::error::{HyperliquidError, Result}; +use crate::types::ws::SubscriptionConfirmation; +use crate::types::ws::SubscriptionResponse; use futures::{stream::SplitStream, StreamExt}; use futures_util::stream::SplitSink; use futures_util::SinkExt; use serde::Serialize; use serde_json::json; -use std::sync::Arc; +use std::collections::HashSet; +use std::hash::{Hash, Hasher}; use tokio::sync::broadcast::{channel, Receiver, Sender}; use tokio::sync::RwLock; +use tokio::task::JoinHandle; use tokio_tungstenite::connect_async; use tokio_tungstenite::{tungstenite::Message, WebSocketStream}; +type WsWriteStream = + SplitSink>, Message>; + +type WsReadStream = + SplitStream>>; + +pub enum StreamMessage { + Subscription(SubscriptionConfirmation), + Heartbeat, +} + /// Configurable parameters for subscriptions. pub struct SubscriptionConfig { // Capacity for the underlying broadcast channel @@ -34,17 +41,188 @@ impl Default for SubscriptionConfig { } } +#[derive(Clone, Eq)] +pub enum SubscriptionSpec { + AllMids { + dex: Option, + }, + Candle { + coin: String, + interval: String, + }, + L2Book { + coin: String, + n_sig_figs: Option, + mantissa: Option, + }, + Trades { + coin: String, + }, + Notifications { + user: String, + }, + WebData3 { + user: String, + }, + TwapStates { + user: String, + }, + ClearinghouseState { + user: String, + }, + OpenOrders { + user: String, + }, + UserEvents { + user: String, + }, + UserFills { + user: String, + }, + UserFunding { + user: String, + }, + UserNonFundingLedgerUpdates { + user: String, + }, + ActiveAssetCtx { + coin: String, + }, + ActiveAssetData { + user: String, + coin: String, + }, + UserTwapSliceFills { + user: String, + }, + UserTwapHistory { + user: String, + }, + Bbo { + coin: String, + }, +} + +impl PartialEq for SubscriptionSpec { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::AllMids { dex: a }, Self::AllMids { dex: b }) => a == b, + + ( + Self::Candle { + coin: a1, + interval: a2, + }, + Self::Candle { + coin: b1, + interval: b2, + }, + ) => a1 == b1 && a2 == b2, + + ( + Self::L2Book { + coin: a, + n_sig_figs: a2, + mantissa: a3, + }, + Self::L2Book { + coin: b, + n_sig_figs: b2, + mantissa: b3, + }, + ) => a == b && a2 == b2 && a3 == b3, + + (Self::Trades { coin: a }, Self::Trades { coin: b }) => a == b, + + (Self::Notifications { user: a }, Self::Notifications { user: b }) => a == b, + (Self::WebData3 { user: a }, Self::WebData3 { user: b }) => a == b, + (Self::TwapStates { user: a }, Self::TwapStates { user: b }) => a == b, + (Self::ClearinghouseState { user: a }, Self::ClearinghouseState { user: b }) => a == b, + (Self::OpenOrders { user: a }, Self::OpenOrders { user: b }) => a == b, + (Self::UserEvents { user: a }, Self::UserEvents { user: b }) => a == b, + (Self::UserFills { user: a }, Self::UserFills { user: b }) => a == b, + (Self::UserFunding { user: a }, Self::UserFunding { user: b }) => a == b, + ( + Self::UserNonFundingLedgerUpdates { user: a }, + Self::UserNonFundingLedgerUpdates { user: b }, + ) => a == b, + + (Self::ActiveAssetCtx { coin: a }, Self::ActiveAssetCtx { coin: b }) => a == b, + + ( + Self::ActiveAssetData { user: a1, coin: a2 }, + Self::ActiveAssetData { user: b1, coin: b2 }, + ) => a1 == b1 && a2 == b2, + + (Self::UserTwapSliceFills { user: a }, Self::UserTwapSliceFills { user: b }) => a == b, + + (Self::UserTwapHistory { user: a }, Self::UserTwapHistory { user: b }) => a == b, + + (Self::Bbo { coin: a }, Self::Bbo { coin: b }) => a == b, + + _ => false, + } + } +} + +impl Hash for SubscriptionSpec { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + match self { + Self::AllMids { dex } => dex.hash(state), + + Self::Candle { coin, interval } => { + coin.hash(state); + interval.hash(state); + } + + Self::L2Book { + coin, + n_sig_figs, + mantissa, + } => { + coin.hash(state); + n_sig_figs.hash(state); + mantissa.hash(state); + } + + Self::Trades { coin } | Self::Bbo { coin } => coin.hash(state), + + Self::Notifications { user } + | Self::WebData3 { user } + | Self::TwapStates { user } + | Self::ClearinghouseState { user } + | Self::OpenOrders { user } + | Self::UserEvents { user } + | Self::UserFills { user } + | Self::UserFunding { user } + | Self::UserNonFundingLedgerUpdates { user } + | Self::UserTwapSliceFills { user } + | Self::UserTwapHistory { user } => user.hash(state), + + Self::ActiveAssetCtx { coin } => coin.hash(state), + + Self::ActiveAssetData { user, coin } => { + user.hash(state); + coin.hash(state); + } + } + } +} + /// A WS client providing access to Hyperliquid Subscriptions API. pub struct SubscriptionClient { - write_stream: SplitSink< - WebSocketStream>, - Message, - >, - streams: Arc>, + pub events: Receiver, + config: SubscriptionConfig, + active_subs: tokio::sync::RwLock>, + write_stream_tx: tokio::sync::mpsc::UnboundedSender, } impl SubscriptionClient { - pub async fn new(client: &HyperliquidClient) -> Result { + pub async fn new( + client: &HyperliquidClient, + config: Option, + ) -> Result { let endpoint = client .ws_endpoint() .ok_or(HyperliquidError::MissingConfiguration { @@ -53,31 +231,82 @@ impl SubscriptionClient { let (ws_stream, _response) = connect_async(endpoint).await?; let (write_stream, read_stream) = ws_stream.split(); - let senders = Arc::new(RwLock::new(StreamSenders::new())); + let config = config.unwrap_or_default(); + let (tx, rx) = channel::(config.channel_capacity); + let (write_stream_tx, write_stream_rx) = + tokio::sync::mpsc::unbounded_channel::(); - let _ = Self::spawn_read_task(senders.clone(), read_stream).await; + let _ = Self::spawn_write_task(write_stream, write_stream_rx); + let _ = Self::spawn_heartbeat(write_stream_tx.clone()); + let _ = Self::spawn_read_task(tx, read_stream); Ok(Self { - write_stream, - streams: senders, + config, + events: rx, + active_subs: RwLock::new(HashSet::new()), + write_stream_tx: write_stream_tx, }) } - fn subscription_channel(capacity: Option) -> (Sender, Receiver) { - channel::(capacity.unwrap_or(1000)) + fn spawn_write_task( + mut write_stream: WsWriteStream, + mut rx: tokio::sync::mpsc::UnboundedReceiver, + ) -> JoinHandle<()> { + tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + match msg { + StreamMessage::Subscription(confirmation) => { + if let Err(e) = write_stream + .send(Message::Text( + serde_json::to_string(&confirmation) + .expect("confirmation to stringify") + .into(), + )) + .await + { + tracing::error!("Failed to send subscription: {:?}", e); + break; + } + if let Err(e) = write_stream.flush().await { + tracing::error!("Failed to flush: {:?}", e); + break; + } + } + StreamMessage::Heartbeat => { + if let Err(e) = write_stream + .send(Message::Text(r#"{"method":"ping"}"#.into())) + .await + { + tracing::error!("Failed to send ping: {:?}", e); + break; + } + } + } + } + tracing::info!("Write task shutting down"); + }) } - async fn send_and_flush(&mut self, confirmation: SubscriptionConfirmation) -> Result<()> { - self.write_stream - .send(Message::Text(serde_json::to_string(&confirmation)?.into())) - .await?; - self.write_stream.flush().await?; + pub fn spawn_heartbeat( + tx: tokio::sync::mpsc::UnboundedSender, + ) -> tokio::task::JoinHandle<()> { + tokio::task::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(50)); + interval.tick().await; - Ok(()) + loop { + interval.tick().await; + + if let Err(e) = tx.send(StreamMessage::Heartbeat) { + tracing::error!("Failed to send heartbeat: {:?}", e); + break; + } + } + }) } - fn spawn_read_task( - senders: Arc>, + pub fn spawn_read_task( + tx: Sender, mut read_stream: SplitStream< WebSocketStream>, >, @@ -92,175 +321,152 @@ impl SubscriptionClient { } }; - match value { - SubscriptionResponse::Error(err) => { - tracing::error!("{:?}", err); - } - SubscriptionResponse::AllMids(mids) => { - if let (Some(tx), Some(_)) = &senders.read().await.all_mids { - if let Err(e) = tx.send(mids) { - tracing::error!("{:?}", e); - } - } - } - _ => {} + if let Err(e) = tx.send(value) { + tracing::error!("{:?}", e); } } }) } - pub async fn unsubscribe(&mut self, key: &SubscriptionKey) -> Result<()> { - let subscription = { - let streams = self.streams.read().await; - Self::build_unsubscribe_payload(&streams, key)? - }; - - let msg = SubscriptionConfirmation { - method: "unsubscribe".into(), - subscription: serde_json::Value::Object(subscription), - }; - - self.send_and_flush(msg).await?; - - { - let mut streams = self.streams.write().await; - Self::clear_subscription(&mut streams, key); - } + fn subscription_channel(capacity: Option) -> (Sender, Receiver) { + channel::(capacity.unwrap_or(1000)) + } + async fn send_and_flush(&mut self, confirmation: SubscriptionConfirmation) -> Result<()> { + self.write_stream_tx + .send(StreamMessage::Subscription(confirmation))?; Ok(()) } - fn build_unsubscribe_payload( - streams: &StreamSenders, - key: &SubscriptionKey, - ) -> Result> { - use serde_json::{Map, Value}; + fn build_subscription_json(spec: &SubscriptionSpec) -> serde_json::Value { + match spec { + SubscriptionSpec::AllMids { dex } => { + let mut v = json!({ "type": "allMids" }); - let mut sub = Map::new(); - - match key { - SubscriptionKey::AllMids => { - let (_, dex) = &streams.all_mids; - Self::ensure_present(streams.all_mids.0.as_ref(), key)?; - sub.insert("type".into(), Value::String("all_mids".into())); if let Some(d) = dex { - sub.insert("dex".into(), Value::String(d.clone())); + if let Some(obj) = v.as_object_mut() { + obj.insert("dex".to_string(), json!(d)); + } } + + v + } + + SubscriptionSpec::Candle { coin, interval } => { + json!({ + "type": "candle", + "coin": coin, + "interval": interval, + }) } - SubscriptionKey::Candle => { - let (_, coin, interval) = &streams.candle; - Self::ensure_present(streams.candle.0.as_ref(), key)?; - sub.insert("type".into(), Value::String("candle".into())); - sub.insert("coin".into(), Value::String(Self::req(coin.as_ref(), key)?)); - sub.insert( - "interval".into(), - Value::String(Self::req(interval.as_ref(), key)?), - ); + SubscriptionSpec::L2Book { + coin, + n_sig_figs, + mantissa, + } => { + let mut v = json!({ + "type": "l2Book", + "coin": coin, + }); + + if let Some(n) = n_sig_figs { + if let Some(obj) = v.as_object_mut() { + obj.insert("nSigFigs".to_string(), json!(n)); + } + } + + if let Some(m) = mantissa { + if let Some(obj) = v.as_object_mut() { + obj.insert("mantissa".to_string(), json!(m)); + } + } + + v } - SubscriptionKey::Trades => { - let (_, coin, _, _) = &streams.trades; - Self::ensure_present(streams.trades.0.as_ref(), key)?; - sub.insert("coin".into(), Value::String(Self::req(coin.as_ref(), key)?)); + SubscriptionSpec::Trades { coin } => { + json!({ + "type": "trades", + "coin": coin, + }) } - SubscriptionKey::L2Book => { - let (_, coin, _, _) = &streams.l2book; - Self::ensure_present(streams.l2book.0.as_ref(), key)?; - sub.insert("coin".into(), Value::String(Self::req(coin.as_ref(), key)?)); + SubscriptionSpec::Notifications { user } => { + json!({ "type": "notification", "user": user }) } - SubscriptionKey::Notification => { - Self::user_only(&mut sub, &streams.notifications, key)?; + SubscriptionSpec::WebData3 { user } => { + json!({ "type": "webData3", "user": user }) } - SubscriptionKey::WebData3 => Self::user_only(&mut sub, &streams.webdata3, key)?, - SubscriptionKey::TwapStates => Self::user_only(&mut sub, &streams.twap_states, key)?, - SubscriptionKey::OpenOrders => Self::user_only(&mut sub, &streams.open_orders, key)?, - SubscriptionKey::UserEvents => Self::user_only(&mut sub, &streams.user_events, key)?, - SubscriptionKey::UserNonFundingLedgerUpdate => { - Self::user_only(&mut sub, &streams.user_non_funding_ledger_updates, key)?; + + SubscriptionSpec::TwapStates { user } => { + json!({ "type": "twapStates", "user": user }) } - SubscriptionKey::ActiveAssetCtx => { - let (_, coin) = &streams.active_asset_ctx; - Self::ensure_present(streams.active_asset_ctx.0.as_ref(), key)?; - sub.insert("coin".into(), Value::String(Self::req(coin.as_ref(), key)?)); + SubscriptionSpec::ClearinghouseState { user } => { + json!({ "type": "clearinghouseState", "user": user }) } - SubscriptionKey::ActiveAssetData => { - let (_, user, coin) = &streams.active_asset_data; - Self::ensure_present(streams.active_asset_data.0.as_ref(), key)?; - sub.insert("user".into(), Value::String(Self::req(user.as_ref(), key)?)); - sub.insert("coin".into(), Value::String(Self::req(coin.as_ref(), key)?)); + SubscriptionSpec::OpenOrders { user } => { + json!({ "type": "openOrders", "user": user }) } - SubscriptionKey::UserTwapSliceFills => { - Self::user_only(&mut sub, &streams.user_twap_slice_fills, key)?; + SubscriptionSpec::UserEvents { user } => { + json!({ "type": "userEvents", "user": user }) } - SubscriptionKey::UserTwapHistory => { - Self::user_only(&mut sub, &streams.user_twap_history, key)?; + SubscriptionSpec::UserFills { user } => { + json!({ "type": "userFills", "user": user }) } - SubscriptionKey::Bbo => Self::user_only(&mut sub, &streams.bbo, key)?, - } + SubscriptionSpec::UserFunding { user } => { + json!({ "type": "userFundings", "user": user }) + } - Ok(sub) - } + SubscriptionSpec::UserNonFundingLedgerUpdates { user } => { + json!({ "type": "userNonFundingLedgerUpdates", "user": user }) + } - fn ensure_present(sender: Option<&Sender>, key: &SubscriptionKey) -> Result<()> { - if sender.is_none() { - Err(Self::missing(key)) - } else { - Ok(()) - } - } + SubscriptionSpec::ActiveAssetCtx { coin } => { + json!({ "type": "activeAssetCtx", "coin": coin }) + } - fn req(v: Option<&String>, key: &SubscriptionKey) -> Result { - v.cloned().ok_or_else(|| Self::missing(key)) - } + SubscriptionSpec::ActiveAssetData { user, coin } => { + json!({ "type": "activeAssetData", "user": user, "coin": coin }) + } - fn user_only( - sub: &mut serde_json::Map, - entry: &(Option>, Option), - key: &SubscriptionKey, - ) -> Result<()> { - Self::ensure_present(entry.0.as_ref(), key)?; - sub.insert( - "user".into(), - serde_json::Value::String(Self::req(entry.1.as_ref(), key)?), - ); - Ok(()) - } + SubscriptionSpec::UserTwapSliceFills { user } => { + json!({ "type": "userTwapSliceFills", "user": user }) + } - fn missing(key: &SubscriptionKey) -> HyperliquidError { - HyperliquidError::SubscriptionError(SubscriptionError::MissingSubscription(key.clone())) - } - fn clear_subscription(streams: &mut StreamSenders, key: &SubscriptionKey) { - match key { - SubscriptionKey::AllMids => streams.all_mids = Default::default(), - SubscriptionKey::Candle => streams.candle = Default::default(), - SubscriptionKey::Trades => streams.trades = Default::default(), - SubscriptionKey::L2Book => streams.l2book = Default::default(), - SubscriptionKey::Notification => streams.notifications = Default::default(), - SubscriptionKey::WebData3 => streams.webdata3 = Default::default(), - SubscriptionKey::TwapStates => streams.twap_states = Default::default(), - SubscriptionKey::OpenOrders => streams.open_orders = Default::default(), - SubscriptionKey::UserEvents => streams.user_events = Default::default(), - SubscriptionKey::UserNonFundingLedgerUpdate => { - streams.user_non_funding_ledger_updates = Default::default(); + SubscriptionSpec::UserTwapHistory { user } => { + json!({ "type": "userTwapHistory", "user": user }) } - SubscriptionKey::ActiveAssetCtx => streams.active_asset_ctx = Default::default(), - SubscriptionKey::ActiveAssetData => streams.active_asset_data = Default::default(), - SubscriptionKey::UserTwapSliceFills => { - streams.user_twap_slice_fills = Default::default(); + + SubscriptionSpec::Bbo { coin } => { + json!({ "type": "bbo", "coin": coin }) } - SubscriptionKey::UserTwapHistory => streams.user_twap_history = Default::default(), - SubscriptionKey::Bbo => streams.bbo = Default::default(), } } + pub async fn unsubscribe(&mut self, spec: &SubscriptionSpec) -> Result<()> { + if !self.active_subs.read().await.contains(spec) { + return Ok(()); + } + + let subscription = Self::build_subscription_json(spec); + + self.send_and_flush(SubscriptionConfirmation { + method: "unsubscribe".into(), + subscription, + }) + .await?; + + self.active_subs.write().await.remove(spec); + Ok(()) + } + /// Subscribes to the [`WsAllMids`] websocket feed. /// /// # Arguments @@ -269,11 +475,13 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender` - pub async fn subscribe_all_mids( - &mut self, - subscription_config: Option, - dex: Option, - ) -> Result> { + pub async fn subscribe_all_mids(&mut self, dex: Option) -> Result<()> { + let spec = SubscriptionSpec::AllMids { dex: dex.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), subscription: json!({ @@ -281,7 +489,7 @@ impl SubscriptionClient { }), }; - if let Some(dex_param) = dex.clone() { + if let Some(dex_param) = dex { if let Some(dex_obj) = subscription_message.clone().subscription.get_mut("dex") { if let Some(obj) = dex_obj.as_object_mut() { let val = serde_json::Value::String(dex_param); @@ -293,26 +501,9 @@ impl SubscriptionClient { self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - { - let write_lock = &mut self.streams.write().await.all_mids; - - if write_lock.0.is_some() { - tracing::error!("Already subscribed to `AllMids`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "allMids".to_string(), - }, - )); - } - - write_lock.1 = dex; - write_lock.0 = Some(tx); - } + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsCandle`] websocket feed. @@ -326,10 +517,19 @@ impl SubscriptionClient { /// A bounded `tokio::sync::broadcast::Sender` pub async fn subscribe_candle( &mut self, - subscription_config: Option, coin: impl Into + Serialize + Clone, interval: String, - ) -> Result> { + ) -> Result<()> { + let coin = coin.into(); + + let spec = SubscriptionSpec::Candle { + coin, + interval: interval.clone(), + }; + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + if !SUPPORTED_INTERVALS.contains(interval.as_str()) { return Err(HyperliquidError::InvalidRequestParameter { method: "subscribe_candle".to_string(), @@ -338,104 +538,58 @@ impl SubscriptionClient { }); } + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "candle", - "coin": coin, - "interval": interval - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - { - let candle = &mut self.streams.write().await.candle; - - if candle.0.is_some() { - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "candle".to_string(), - }, - )); - } - - candle.0 = Some(tx); - candle.1 = Some(coin.clone().into()); - candle.2 = Some(interval.clone()); - } + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsBook`] websocket feed. /// /// # Arguments /// * `coin` The desired asset for returning l2book data. + /// * `n_sig_figs` - Optional number of significant figures. May cause subscription + /// to not receive data on testnet if set. Use `None` if unsure. + /// * `mantissa` - Optional mantissa. May cause subscription to not receive data + /// on testnet if set. Use `None` if unsure. /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender` pub async fn subscribe_l2_book( &mut self, - subscription_config: Option, coin: impl Into + Serialize, n_sig_figs: Option, mantissa: Option, - ) -> Result> { + ) -> Result<()> { let coin = coin.into(); - - let mut subscription_message = SubscriptionConfirmation { - method: "subscribe".to_string(), - subscription: json!({ - "type": "l2book", - "coin": coin.clone(), - }), + let spec = SubscriptionSpec::L2Book { + coin: coin.clone(), + n_sig_figs, + mantissa, }; - let subscription_data = subscription_message - .subscription - .as_object_mut() - .expect("subscription object to be valid"); - if let Some(sig_figs) = n_sig_figs { - subscription_data.insert( - "nSigFigs".to_string(), - serde_json::Value::Number(sig_figs.into()), - ); + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - if let Some(mantissa) = mantissa { - subscription_data.insert( - "mantissa".to_string(), - serde_json::Value::Number(mantissa.into()), - ); - } + let subscription = Self::build_subscription_json(&spec); + let subscription_message = SubscriptionConfirmation { + method: "subscribe".to_string(), + subscription, + }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - { - let stream_lock = &mut self.streams.write().await.l2book; - if stream_lock.0.is_some() { - tracing::error!("Already subscribed to `l2book`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "l2book".to_string(), - }, - )); - } + self.active_subs.write().await.insert(spec); - stream_lock.0 = Some(tx); - stream_lock.1 = Some(coin); - stream_lock.2 = n_sig_figs; - stream_lock.3 = mantissa; - } - - Ok(rx) + Ok(()) } /// Subscribes to the [`WsTrade`] websocket feed. @@ -447,38 +601,26 @@ impl SubscriptionClient { /// A bounded `tokio::sync::broadcast::Sender` pub async fn subscribe_trades( &mut self, - subscription_config: Option, coin: impl Into + Serialize + Clone, - ) -> Result> { + ) -> Result<()> { + let coin = coin.into(); + let spec = SubscriptionSpec::Trades { coin: coin.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "trades", - "coin": coin, - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - { - let stream_lock = &mut self.streams.write().await.trades; - if stream_lock.0.is_some() { - tracing::error!("Already subscribed to `trades`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "trades".to_string(), - }, - )); - } - - stream_lock.0 = Some(tx); - stream_lock.1 = Some(coin.clone().into()); - } + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsNotification`] websocket feed. @@ -488,39 +630,26 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender` - pub async fn subscribe_notifications( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { + pub async fn subscribe_notifications(&mut self, user: impl Into) -> Result<()> { + let user = user.into(); + + let spec = SubscriptionSpec::Notifications { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "notification", - "user": json!(user.into()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - { - let stream_lock = &mut self.streams.write().await.notifications; - if stream_lock.0.is_some() { - tracing::error!("Already subscribed to `notifications`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "notifications".to_string(), - }, - )); - } + self.active_subs.write().await.insert(spec); - stream_lock.0 = Some(tx); - } - - Ok(rx) + Ok(()) } /// Subscribes to the [`WsWebData3`] websocket feed. @@ -530,42 +659,26 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_webdata3( - &mut self, - subscription_config: Option, - user: impl Into + Serialize + Clone, - ) -> Result> { + pub async fn subscribe_webdata3(&mut self, user: impl Into) -> Result<()> { let user = user.into(); + let spec = SubscriptionSpec::WebData3 { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "webdata3", - "user": json!(user.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - { - let (opt_tx, opt_user) = &mut self.streams.write().await.webdata3; - if opt_tx.is_some() { - tracing::error!("Already subscribed to `webdata3`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "webData3".to_string(), - }, - )); - } + self.active_subs.write().await.insert(spec); - *opt_tx = Some(tx); - *opt_user = Some(user); - } - - Ok(rx) + Ok(()) } /// Subscribes to the [`WsTwapStates`] websocket feed. @@ -575,42 +688,26 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_twap_states( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { + pub async fn subscribe_twap_states(&mut self, user: impl Into) -> Result<()> { let user = user.into(); + let spec = SubscriptionSpec::TwapStates { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "twapStates", - "user": json!(user.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - { - let (opt_tx, opt_user) = &mut self.streams.write().await.twap_states; - if opt_tx.is_some() { - tracing::error!("Already subscribed to `twapStates`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "twapStates".to_string(), - }, - )); - } - - *opt_tx = Some(tx); - *opt_user = Some(user); - } + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsClearinghouseState`] websocket feed. @@ -620,40 +717,26 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_clearinghouse_state( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { - if self.streams.read().await.clearinghouse_state.0.is_some() { - tracing::error!("Already subscribed to `clearinghouseState"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "clearinghouseState".to_string(), - }, - )); - } - + pub async fn subscribe_clearinghouse_state(&mut self, user: impl Into) -> Result<()> { let user = user.into(); + let spec = SubscriptionSpec::ClearinghouseState { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "clearinghouseState", - "user": json!(user.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - let write_lock = &mut self.streams.write().await.clearinghouse_state; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsOpenOrders`] websocket feed. @@ -663,40 +746,26 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_open_orders( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { - if self.streams.read().await.open_orders.0.is_some() { - tracing::error!("Already subscribed to `openOrders"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "openOrders".to_string(), - }, - )); + pub async fn subscribe_open_orders(&mut self, user: impl Into) -> Result<()> { + let user = user.into(); + + let spec = SubscriptionSpec::OpenOrders { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - let user = user.into(); + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "openOrders", - "user": json!(user.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; + self.active_subs.write().await.insert(spec); - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - let write_lock = &mut self.streams.write().await.open_orders; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); - - Ok(rx) + Ok(()) } /// Subscribes to the [`WsUserEvent`] websocket feed. @@ -706,40 +775,26 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_events( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { - if self.streams.read().await.user_events.0.is_some() { - tracing::error!("Already subscribed to `userEvents`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "userEvents".to_string(), - }, - )); - } - + pub async fn subscribe_user_events(&mut self, user: impl Into) -> Result<()> { let user = user.into(); + let spec = SubscriptionSpec::UserEvents { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "userEvents", - "user": json!(user.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + self.active_subs.write().await.insert(spec); - let write_lock = &mut self.streams.write().await.user_events; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); - - Ok(rx) + Ok(()) } /// Subscribes to the [`WsUserFills`] websocket feed. @@ -749,40 +804,27 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_fills( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { - if self.streams.read().await.user_fills.0.is_some() { - tracing::error!("Already subscribed to `userFills`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "userFills".to_string(), - }, - )); + pub async fn subscribe_user_fills(&mut self, user: impl Into) -> Result<()> { + let user = user.into(); + + let spec = SubscriptionSpec::UserFills { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - let user = user.into(); + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "userFills", - "user": json!(user.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - let write_lock = &mut self.streams.write().await.user_fills; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsUserFunding`] websocket feed. @@ -792,40 +834,27 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_funding( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { - if self.streams.read().await.user_funding.0.is_some() { - tracing::error!("Already subscribed to `userFunding`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "userFunding".to_string(), - }, - )); + pub async fn subscribe_user_funding(&mut self, user: impl Into) -> Result<()> { + let user = user.into(); + + let spec = SubscriptionSpec::UserFunding { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - let user = user.into(); + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "userFunding", - "user": json!(user.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + self.active_subs.write().await.insert(spec); - let write_lock = &mut self.streams.write().await.user_funding; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); - - Ok(rx) + Ok(()) } /// Subscribes to the [`WsUserNonFundingLedgerUpdate`] websocket feed. @@ -837,45 +866,29 @@ impl SubscriptionClient { /// A bounded `tokio::sync::broadcast::Sender`. pub async fn subscribe_user_non_funding_ledger_updates( &mut self, - subscription_config: Option, + user: impl Into, - ) -> Result> { - if self - .streams - .read() - .await - .user_non_funding_ledger_updates - .0 - .is_some() - { - tracing::error!("Already subscribed to `userNonFundingLedgerUpdates`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "userNonFundingLedgerUpdates".to_string(), - }, - )); + ) -> Result<()> { + let user = user.into(); + + let spec = SubscriptionSpec::UserNonFundingLedgerUpdates { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - let user = user.into(); + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "userNonFundingLedgerUpdates", - "user": json!(user.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - let write_lock = &mut self.streams.write().await.user_non_funding_ledger_updates; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsActiveAssetCtx`] websocket feed. @@ -885,40 +898,27 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_active_asset_ctx( - &mut self, - subscription_config: Option, - coin: impl Into + Clone, - ) -> Result> { - if self.streams.read().await.active_asset_ctx.0.is_some() { - tracing::error!("Already subscribed to `activeAssetCtx`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "activeAssetCtx".to_string(), - }, - )); + pub async fn subscribe_active_asset_ctx(&mut self, coin: impl Into) -> Result<()> { + let coin = coin.into(); + + let spec = SubscriptionSpec::ActiveAssetCtx { coin: coin.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - let coin = coin.into(); + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "activeAssetCtx", - "coin": json!(coin.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - let write_lock = &mut self.streams.write().await.active_asset_ctx; - write_lock.0 = Some(tx); - write_lock.1 = Some(coin); + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsActiveAssetData`] websocket feed. @@ -931,42 +931,34 @@ impl SubscriptionClient { /// A bounded `tokio::sync::broadcast::Sender`. pub async fn subscribe_active_asset_data( &mut self, - subscription_config: Option, + user: impl Into, coin: impl Into, - ) -> Result> { - if self.streams.read().await.active_asset_data.0.is_some() { - tracing::error!("Already subscribed to `activeAssetData`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "activeAssetData".to_string(), - }, - )); - } - + ) -> Result<()> { let user = user.into(); let coin = coin.into(); + let spec = SubscriptionSpec::ActiveAssetData { + coin: coin.clone(), + user: user.clone(), + }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); + } + + let subscription = Self::build_subscription_json(&spec); + let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "activeAssetData", - "user": json!(user.clone()), - "coin": json!(coin.clone()) - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - let write_lock = &mut self.streams.write().await.active_asset_data; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); - write_lock.2 = Some(coin); + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsUserTwapSliceFills`] websocket feed. @@ -977,40 +969,27 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_twap_slice_fills( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { - if self.streams.read().await.user_twap_slice_fills.0.is_some() { - tracing::error!("Already subscribed to `userTwapSliceFills`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "userTwapSliceFills".to_string(), - }, - )); + pub async fn subscribe_user_twap_slice_fills(&mut self, user: impl Into) -> Result<()> { + let user = user.into(); + + let spec = SubscriptionSpec::UserTwapSliceFills { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - let user = user.into(); + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "userTwapSliceFills", - "user": json!(user.clone()), - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + self.active_subs.write().await.insert(spec); - let write_lock = &mut self.streams.write().await.user_twap_slice_fills; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); - - Ok(rx) + Ok(()) } /// Subscribes to the [`WsUserTwapHistory`] websocket feed. @@ -1021,40 +1000,27 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_user_twap_history( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { - if self.streams.read().await.user_twap_history.0.is_some() { - tracing::error!("Already subscribed to `userTwapHistory`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "userTwapHistory".to_string(), - }, - )); + pub async fn subscribe_user_twap_history(&mut self, user: impl Into) -> Result<()> { + let user = user.into(); + + let spec = SubscriptionSpec::UserTwapHistory { user: user.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - let user = user.into(); + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "userTwapHistory", - "user": json!(user.clone()), - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); - - let write_lock = &mut self.streams.write().await.user_twap_history; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); + self.active_subs.write().await.insert(spec); - Ok(rx) + Ok(()) } /// Subscribes to the [`WsBbo`] websocket feed. @@ -1065,39 +1031,26 @@ impl SubscriptionClient { /// /// # Returns /// A bounded `tokio::sync::broadcast::Sender`. - pub async fn subscribe_bbo( - &mut self, - subscription_config: Option, - user: impl Into, - ) -> Result> { - if self.streams.read().await.bbo.0.is_some() { - tracing::error!("Already subscribed to `bbo`"); - return Err(HyperliquidError::SubscriptionError( - SubscriptionError::SubscriptionExist { - method: "bbo".to_string(), - }, - )); + pub async fn subscribe_bbo(&mut self, coin: impl Into) -> Result<()> { + let coin = coin.into(); + + let spec = SubscriptionSpec::Bbo { coin: coin.clone() }; + + if self.active_subs.read().await.contains(&spec) { + return Ok(()); } - let user = user.into(); + let subscription = Self::build_subscription_json(&spec); let subscription_message = SubscriptionConfirmation { method: "subscribe".to_string(), - subscription: json!({ - "type": "bbo", - "user": json!(user.clone()), - }), + subscription, }; self.send_and_flush(subscription_message).await?; - let capacity = subscription_config.unwrap_or_default().channel_capacity; - let (tx, rx) = Self::subscription_channel::(Some(capacity)); + self.active_subs.write().await.insert(spec); - let write_lock = &mut self.streams.write().await.bbo; - write_lock.0 = Some(tx); - write_lock.1 = Some(user); - - Ok(rx) + Ok(()) } } diff --git a/src/client/client.rs b/src/client/client.rs index 5566b35..b157bf1 100644 --- a/src/client/client.rs +++ b/src/client/client.rs @@ -85,7 +85,7 @@ impl HyperliquidClient { } pub async fn subscriptions(&self) -> Result { - SubscriptionClient::new(self).await + SubscriptionClient::new(self, None).await } pub fn is_mainnet(&self) -> bool { diff --git a/src/error.rs b/src/error.rs index dede35e..1eab4d0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,8 +1,9 @@ use alloy::primitives::SignatureError; -use std::{result::Result as StdResult, string::FromUtf8Error}; +use std::{result::Result as StdResult, string::FromUtf8Error, sync::mpsc::SendError}; use thiserror::Error; +use tokio::sync::broadcast::error::RecvError; -use crate::types::ws::SubscriptionKey; +use crate::{api::StreamMessage, types::ws::SubscriptionKey}; #[derive(Error, Debug)] pub enum SubscriptionError { @@ -31,6 +32,12 @@ pub enum HyperliquidError { #[error("Function requires wallet/signer.")] SignerRequired, #[error("{0}")] + SendError(#[from] SendError), + #[error("{0}")] + RecvError(#[from] RecvError), + #[error("{0}")] + WebSocketError(String), + #[error("{0}")] AlloySignError(#[from] alloy::signers::Error), #[error("{0}")] RmpSerde(#[from] rmp_serde::encode::Error), @@ -58,4 +65,10 @@ pub enum HyperliquidError { GenericParse(String), } +impl From> for HyperliquidError { + fn from(e: tokio::sync::mpsc::error::SendError) -> Self { + HyperliquidError::WebSocketError(format!("Channel send failed: {}", e)) + } +} + pub type Result = StdResult; diff --git a/src/types/serialize.rs b/src/types/serialize.rs index d91365c..195e6a5 100644 --- a/src/types/serialize.rs +++ b/src/types/serialize.rs @@ -21,3 +21,32 @@ where { serializer.serialize_str(&format!("0x{:x}", chain_id)) } + +pub(crate) mod decimal_array { + use rust_decimal::Decimal; + use serde::{Deserialize, Deserializer, Serializer}; + + /// Serializer for a fixed array of length(2) + pub fn serialize(value: &[Decimal; 2], serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeTuple; + let mut seq = serializer.serialize_tuple(2)?; + seq.serialize_element(&value[0].to_string())?; + seq.serialize_element(&value[1].to_string())?; + seq.end() + } + + /// Deserializer for a fixed array of length(2) + pub fn deserialize<'de, D>(deserializer: D) -> Result<[Decimal; 2], D::Error> + where + D: Deserializer<'de>, + { + let strings: [String; 2] = Deserialize::deserialize(deserializer)?; + Ok([ + strings[0].parse().map_err(serde::de::Error::custom)?, + strings[1].parse().map_err(serde::de::Error::custom)?, + ]) + } +} diff --git a/src/types/ws.rs b/src/types/ws.rs index b2a0e7d..408038a 100644 --- a/src/types/ws.rs +++ b/src/types/ws.rs @@ -1,7 +1,11 @@ +use rust_decimal::Decimal; /// Request and response types for WebSocket subscriptions /// and streaming data. use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::primitive::str; + +use crate::types::serialize::decimal_array; /// WebSocket trade data #[derive(Debug, Clone, Serialize, Deserialize)] @@ -79,15 +83,20 @@ pub struct WsCandle { /// Interval pub i: String, /// Open price - pub o: f64, + #[serde(with = "rust_decimal::serde::str")] + pub o: Decimal, /// Close price - pub c: f64, + #[serde(with = "rust_decimal::serde::str")] + pub c: Decimal, /// High price - pub h: f64, + #[serde(with = "rust_decimal::serde::str")] + pub h: Decimal, /// Low price - pub l: f64, + #[serde(with = "rust_decimal::serde::str")] + pub l: Decimal, /// Volume (base unit) - pub v: f64, + #[serde(with = "rust_decimal::serde::str")] + pub v: Decimal, /// Number of trades pub n: u64, } @@ -101,7 +110,7 @@ pub enum WsUserEvent { fills: Vec, }, Funding { - funding: WsUserFunding, + funding: WsUserFundings, }, Liquidation { liquidation: WsLiquidation, @@ -127,7 +136,8 @@ pub struct WsUserFills { pub struct FillLiquidation { #[serde(skip_serializing_if = "Option::is_none")] pub liquidated_user: Option, - pub mark_px: f64, + #[serde(with = "rust_decimal::serde::str")] + pub mark_px: Decimal, /// "market" | "backstop" pub method: String, } @@ -166,10 +176,18 @@ pub struct WsFill { pub builder_fee: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WsUserFundings { + pub is_snapshot: bool, + pub user: String, + pub fundings: Vec +} + /// WebSocket user funding payment #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct WsUserFunding { +pub struct Fundings { pub time: u64, pub coin: String, pub usdc: String, @@ -224,65 +242,68 @@ pub struct WsBasicOrder { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SharedAssetCtx { - pub day_ntl_vlm: f64, - pub prev_day_px: f64, - pub mark_px: f64, + #[serde(with = "rust_decimal::serde::str")] + pub day_ntl_vlm: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub prev_day_px: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub mark_px: Decimal, #[serde(skip_serializing_if = "Option::is_none")] - pub mid_px: Option, + pub mid_px: Option, } /// Perpetuals asset context #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PerpsAssetCtx { - pub day_ntl_vlm: f64, - pub prev_day_px: f64, - pub mark_px: f64, - #[serde(skip_serializing_if = "Option::is_none")] - pub mid_px: Option, - pub funding: f64, - pub open_interest: f64, - pub oracle_px: f64, + #[serde(with = "rust_decimal::serde::str")] + pub day_ntl_vlm: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub prev_day_px: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub mark_px: Decimal, + #[serde( + with = "rust_decimal::serde::str_option", + skip_serializing_if = "Option::is_none" + )] + pub mid_px: Option, + #[serde(with = "rust_decimal::serde::str")] + pub funding: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub open_interest: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub oracle_px: Decimal, } /// Spot asset context #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SpotAssetCtx { - pub day_ntl_vlm: f64, - pub prev_day_px: f64, - pub mark_px: f64, + #[serde(with = "rust_decimal::serde::str")] + pub day_ntl_vlm: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub prev_day_px: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub mark_px: Decimal, #[serde(skip_serializing_if = "Option::is_none")] - pub mid_px: Option, - pub circulating_supply: f64, + pub mid_px: Option, + #[serde(with = "rust_decimal::serde::str")] + pub circulating_supply: Decimal, } -/// WebSocket active perpetuals asset context -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WsActiveAssetCtx { - pub coin: String, - pub ctx: PerpsAssetCtx, -} - -/// WebSocket active spot asset context -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WsActiveSpotAssetCtx { - pub coin: String, - pub ctx: SpotAssetCtx, -} /// Leverage information #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Leverage { - pub raw_usd: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_usd: Option, #[serde(rename = "type")] pub leverage_type: String, pub value: u32, } + /// WebSocket active asset data for a user #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -290,8 +311,10 @@ pub struct WsActiveAssetData { pub user: String, pub coin: String, pub leverage: Leverage, - pub max_trade_szs: [f64; 2], - pub available_to_trade: [f64; 2], + #[serde(with = "decimal_array")] + pub max_trade_szs: [Decimal; 2], + #[serde(with = "decimal_array")] + pub available_to_trade: [Decimal; 2], } /// WebSocket TWAP slice fill @@ -319,9 +342,12 @@ pub struct WsTwapState { pub coin: String, pub user: String, pub side: String, - pub sz: f64, - pub executed_sz: f64, - pub executed_ntl: f64, + #[serde(with = "rust_decimal::serde::str")] + pub sz: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub executed_sz: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub executed_ntl: Decimal, pub minutes: u32, pub reduce_only: bool, pub randomize: bool, @@ -363,7 +389,8 @@ pub struct UserState { pub agent_address: Option, pub agent_valid_until: Option, pub server_time: u64, - pub cum_ledger: f64, + #[serde(with = "rust_decimal::serde::str")] + pub cum_ledger: Decimal, pub is_vault: bool, pub user: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -384,7 +411,8 @@ pub struct LeadingVault { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PerpDexState { - pub total_vault_equity: f64, + #[serde(with = "rust_decimal::serde::str")] + pub total_vault_equity: Decimal, #[serde(skip_serializing_if = "Option::is_none")] pub perps_at_open_interest_cap: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -403,10 +431,14 @@ pub struct WsWebData3 { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MarginSummary { - pub account_value: f64, - pub total_ntl_pos: f64, - pub total_raw_usd: f64, - pub total_margin_used: f64, + #[serde(with = "rust_decimal::serde::str")] + pub account_value: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub total_ntl_pos: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub total_raw_usd: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub total_margin_used: Decimal, } /// Position information @@ -437,11 +469,22 @@ pub struct AssetPosition { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsClearinghouseState { + pub dex: String, + pub user: String, + pub clearinghouse_state: ClearinghouseStateData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClearinghouseStateData { pub asset_positions: Vec, pub margin_summary: MarginSummary, pub cross_margin_summary: MarginSummary, - pub cross_maintenance_margin_used: f64, - pub withdrawable: f64, + #[serde(with = "rust_decimal::serde::str")] + pub cross_maintenance_margin_used: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub withdrawable: Decimal, + pub time: u64, } /// Order information @@ -475,15 +518,23 @@ pub struct WsTwapStates { pub states: Vec<(u64, WsTwapState)>, } -/// WebSocket user non-funding ledger update #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct WsUserNonFundingLedgerUpdate { +pub struct WsNonFundingLedgerUpdate { pub time: u64, pub hash: String, pub delta: WsLedgerUpdate, } +/// WebSocket user non-funding ledger update +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WsUserNonFundingLedgerUpdate { + pub is_snapshot: bool, + pub user: String, + pub non_funding_ledger_updates: Vec +} + /// WebSocket ledger update /// Can be deposit, withdraw, transfer, liquidation, vault operations, etc. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -523,33 +574,39 @@ pub enum WsLedgerUpdate { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsDeposit { - pub usdc: f64, + #[serde(with = "rust_decimal::serde::str")] + pub usdc: Decimal, } /// Withdraw ledger update #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsWithdraw { - pub usdc: f64, + #[serde(with = "rust_decimal::serde::str")] + pub usdc: Decimal, pub nonce: u64, - pub fee: f64, + #[serde(with = "rust_decimal::serde::str")] + pub fee: Decimal, } /// Internal transfer ledger update #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsInternalTransfer { - pub usdc: f64, + #[serde(with = "rust_decimal::serde::str")] + pub usdc: Decimal, pub user: String, pub destination: String, - pub fee: f64, + #[serde(with = "rust_decimal::serde::str")] + pub fee: Decimal, } /// Sub-account transfer ledger update #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsSubAccountTransfer { - pub usdc: f64, + #[serde(with = "rust_decimal::serde::str")] + pub usdc: Decimal, pub user: String, pub destination: String, } @@ -559,7 +616,8 @@ pub struct WsSubAccountTransfer { #[serde(rename_all = "camelCase")] pub struct LiquidatedPosition { pub coin: String, - pub szi: f64, + #[serde(with = "rust_decimal::serde::str")] + pub szi: Decimal, } /// Liquidation ledger update @@ -567,7 +625,8 @@ pub struct LiquidatedPosition { #[serde(rename_all = "camelCase")] pub struct WsLedgerLiquidation { /// For isolated positions this is the isolated account value - pub account_value: f64, + #[serde(with = "rust_decimal::serde::str")] + pub account_value: Decimal, /// "Cross" | "Isolated" pub leverage_type: String, pub liquidated_positions: Vec, @@ -578,7 +637,8 @@ pub struct WsLedgerLiquidation { #[serde(rename_all = "camelCase")] pub struct WsVaultDelta { pub vault: String, - pub usdc: f64, + #[serde(with = "rust_decimal::serde::str")] + pub usdc: Decimal, } /// Vault withdrawal ledger update @@ -587,11 +647,16 @@ pub struct WsVaultDelta { pub struct WsVaultWithdrawal { pub vault: String, pub user: String, - pub requested_usd: f64, - pub commission: f64, - pub closing_cost: f64, - pub basis: f64, - pub net_withdrawn_usd: f64, + #[serde(with = "rust_decimal::serde::str")] + pub requested_usd: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub commission: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub closing_cost: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub basis: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub net_withdrawn_usd: Decimal, } /// Vault leader commission ledger update @@ -599,7 +664,8 @@ pub struct WsVaultWithdrawal { #[serde(rename_all = "camelCase")] pub struct WsVaultLeaderCommission { pub user: String, - pub usdc: f64, + #[serde(with = "rust_decimal::serde::str")] + pub usdc: Decimal, } /// Spot transfer ledger update @@ -607,18 +673,22 @@ pub struct WsVaultLeaderCommission { #[serde(rename_all = "camelCase")] pub struct WsSpotTransfer { pub token: String, - pub amount: f64, - pub usdc_value: f64, + #[serde(with = "rust_decimal::serde::str")] + pub amount: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub usdc_value: Decimal, pub user: String, pub destination: String, - pub fee: f64, + #[serde(with = "rust_decimal::serde::str")] + pub fee: Decimal, } /// Account class transfer ledger update #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsAccountClassTransfer { - pub usdc: f64, + #[serde(with = "rust_decimal::serde::str")] + pub usdc: Decimal, pub to_perp: bool, } @@ -627,14 +697,16 @@ pub struct WsAccountClassTransfer { #[serde(rename_all = "camelCase")] pub struct WsSpotGenesis { pub token: String, - pub amount: f64, + #[serde(with = "rust_decimal::serde::str")] + pub amount: Decimal, } /// Rewards claim ledger update #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsRewardsClaim { - pub amount: f64, + #[serde(with = "rust_decimal::serde::str")] + pub amount: Decimal, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -648,12 +720,22 @@ pub struct SubscriptionConfirmation { pub subscription: serde_json::Value, } +/// WebSocket active perpetuals asset context +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WsActiveAssetCtx { + pub coin: String, + pub ctx: PerpsAssetCtx, +} + #[derive(Serialize, Deserialize, Clone, Debug)] -pub enum WsAssetCtx { - Ctx(WsActiveAssetCtx), - Data(WsActiveSpotAssetCtx), +#[serde(rename_all = "camelCase")] +pub struct WsActiveSpotAssetCtx { + pub coin: String, + pub ctx: SpotAssetCtx, } + /// Identifies a concrete websocket subscription type supported by the feed. /// /// Each variant corresponds to a distinct server-side stream and determines @@ -675,6 +757,7 @@ pub enum SubscriptionKey { UserTwapSliceFills, UserTwapHistory, Bbo, + Ping, } impl std::fmt::Display for SubscriptionKey { @@ -695,6 +778,7 @@ impl std::fmt::Display for SubscriptionKey { Self::UserTwapSliceFills => f.write_str("userTwapSliceFills"), Self::UserTwapHistory => f.write_str("userTwapHistory"), Self::Bbo => f.write_str("bbo"), + Self::Ping => f.write_str("pong"), }; Ok(()) @@ -705,7 +789,7 @@ impl std::fmt::Display for SubscriptionKey { /// /// The `channel` field selects the subscription stream, while `data` contains /// the stream-specific payload deserialized into a strongly typed variant. -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "channel", content = "data", rename_all = "camelCase")] pub enum SubscriptionResponse { /// Server-side error emitted over the websocket connection. @@ -715,17 +799,24 @@ pub enum SubscriptionResponse { /// Acknowledgement or status response for subscribe / unsubscribe requests. SubscriptionResponse(SubscriptionConfirmation), + /// Pong response to client ping (keepalive) + #[serde(rename = "pong")] + Pong, + AllMids(WsAllMids), Candle(WsCandle), - Trades(WsTrade), + Trades(Vec), L2Book(WsBook), Notification(WsNotification), WebData3(WsWebData3), + ClearinghouseState(WsClearinghouseState), TwapStates(WsTwapStates), + UserFills(WsUserFills), OpenOrders(WsOpenOrders), UserEvents(WsUserEvent), - UserNonFundingLedgerUpdate(WsUserNonFundingLedgerUpdate), - ActiveAssetCtx(WsAssetCtx), + UserFundings(WsUserFundings), + UserNonFundingLedgerUpdates(WsUserNonFundingLedgerUpdate), + ActiveAssetCtx(WsActiveAssetCtx), ActiveAssetData(WsActiveAssetData), UserTwapSliceFills(WsUserTwapSliceFills), UserTwapHistory(WsUserTwapHistory), From dd5e3faab43ca1c118bef6296376c5d6ecb19ee4 Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Fri, 19 Dec 2025 02:18:11 -0500 Subject: [PATCH 6/9] Add WS error types --- src/types/ws.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/types/ws.rs b/src/types/ws.rs index 408038a..bf7bd89 100644 --- a/src/types/ws.rs +++ b/src/types/ws.rs @@ -785,6 +785,24 @@ impl std::fmt::Display for SubscriptionKey { } } +/// Detailed error response from order/cancel operations +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WsErrorResponse { + pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// Batch operation error (same length as request) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchError { + #[serde(rename = "type")] + pub error_type: String, + pub message: String, +} + /// Messages delivered over websocket subscription channels. /// /// The `channel` field selects the subscription stream, while `data` contains @@ -794,7 +812,7 @@ impl std::fmt::Display for SubscriptionKey { pub enum SubscriptionResponse { /// Server-side error emitted over the websocket connection. #[serde(rename = "error")] - Error(String), + Error(WsErrorResponse), /// Acknowledgement or status response for subscribe / unsubscribe requests. SubscriptionResponse(SubscriptionConfirmation), From c87ada1baf2a077d7763c62b5e760f875421d43c Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Fri, 19 Dec 2025 11:22:29 -0500 Subject: [PATCH 7/9] cargo clippy,fmt resolution --- examples/subscriptions.rs | 103 +++++++++++++++++++++++++-------- src/api/subscription/sender.rs | 2 +- src/api/subscription/ws.rs | 49 ++++++++-------- src/error.rs | 2 +- src/types/serialize.rs | 1 - src/types/ws.rs | 17 +++--- 6 files changed, 113 insertions(+), 61 deletions(-) diff --git a/examples/subscriptions.rs b/examples/subscriptions.rs index b8dbd6a..1fa85fb 100644 --- a/examples/subscriptions.rs +++ b/examples/subscriptions.rs @@ -23,9 +23,11 @@ async fn main() -> Result<(), Box> { subs.subscribe_user_events(user.clone()).await?; subs.subscribe_user_fills(user.clone()).await?; subs.subscribe_user_funding(user.clone()).await?; - subs.subscribe_user_non_funding_ledger_updates(user.clone()).await?; + subs.subscribe_user_non_funding_ledger_updates(user.clone()) + .await?; subs.subscribe_active_asset_ctx("BTC").await?; - subs.subscribe_active_asset_data(user.clone(), "BTC").await?; + subs.subscribe_active_asset_data(user.clone(), "BTC") + .await?; subs.subscribe_user_twap_slice_fills(user.clone()).await?; subs.subscribe_user_twap_history(user.clone()).await?; subs.subscribe_bbo("BTC").await?; @@ -33,27 +35,82 @@ async fn main() -> Result<(), Box> { // Match and receive subscription messages while let Ok(msg) = subs.events.recv().await { match msg { - rhyperliquid::types::ws::SubscriptionResponse::Error(e)=>{tracing::info!("Error: {:?}",e);} - rhyperliquid::types::ws::SubscriptionResponse::SubscriptionResponse(subscription_confirmation,)=>{tracing::info!("SubscriptionResponse: {:?}",subscription_confirmation);} - rhyperliquid::types::ws::SubscriptionResponse::AllMids(ws_all_mids)=>{tracing::info!("AllMids: {:?}",ws_all_mids);} - rhyperliquid::types::ws::SubscriptionResponse::Candle(ws_candle)=>{tracing::info!("Candle: {:?}",ws_candle);} - rhyperliquid::types::ws::SubscriptionResponse::Trades(ws_trade)=>{tracing::info!("Trades: {:?}",ws_trade);} - rhyperliquid::types::ws::SubscriptionResponse::L2Book(ws_book)=>{tracing::info!("L2Book: {:?}",ws_book);} - rhyperliquid::types::ws::SubscriptionResponse::Notification(ws_notification)=>{tracing::info!("Notification: {:?}",ws_notification);} - rhyperliquid::types::ws::SubscriptionResponse::WebData3(ws_web_data3)=>{tracing::info!("WebData3: {:?}",ws_web_data3);} - rhyperliquid::types::ws::SubscriptionResponse::TwapStates(ws_twap_states)=>{tracing::info!("TwapStates: {:?}",ws_twap_states);} - rhyperliquid::types::ws::SubscriptionResponse::OpenOrders(ws_open_orders)=>{tracing::info!("OpenOrders: {:?}",ws_open_orders);} - rhyperliquid::types::ws::SubscriptionResponse::UserEvents(ws_user_event)=>{tracing::info!("UserEvents: {:?}",ws_user_event);} - rhyperliquid::types::ws::SubscriptionResponse::UserNonFundingLedgerUpdates(ws_user_non_funding_ledger_update,)=>{tracing::info!("UserNonFundingLedgerUpdate: {:?}",ws_user_non_funding_ledger_update);} - rhyperliquid::types::ws::SubscriptionResponse::ActiveAssetCtx(ws_asset_ctx)=>{tracing::info!("ActiveAssetCtx: {:?}",ws_asset_ctx);} - rhyperliquid::types::ws::SubscriptionResponse::ActiveAssetData(ws_active_asset_data,)=>{tracing::info!("ActiveAssetData: {:?}",ws_active_asset_data);} - rhyperliquid::types::ws::SubscriptionResponse::UserTwapSliceFills(ws_user_twap_slice_fills,)=>{tracing::info!("UserTwapSliceFills: {:?}",ws_user_twap_slice_fills);} - rhyperliquid::types::ws::SubscriptionResponse::UserTwapHistory(ws_user_twap_history,)=>{tracing::info!("UserTwapHistory: {:?}",ws_user_twap_history);} - rhyperliquid::types::ws::SubscriptionResponse::Bbo(ws_bbo)=>{tracing::info!("Bbo: {:?}",ws_bbo);} - rhyperliquid::types::ws::SubscriptionResponse::Pong=>tracing::info!("Pong"), - rhyperliquid::types::ws::SubscriptionResponse::ClearinghouseState(ws_clearinghouse_state,)=>{tracing::info!("ClearinghouseState: {:?}",ws_clearinghouse_state);} - rhyperliquid::types::ws::SubscriptionResponse::UserFills(ws_user_fills)=>{tracing::info!("User Fills: {:?}",ws_user_fills);} - rhyperliquid::types::ws::SubscriptionResponse::UserFundings(ws_user_fundings) => {tracing::info!("User Fundings: {:?}",ws_user_fundings);} + rhyperliquid::types::ws::SubscriptionResponse::Error(e) => { + tracing::info!("Error: {:?}", e); + } + rhyperliquid::types::ws::SubscriptionResponse::SubscriptionResponse( + subscription_confirmation, + ) => { + tracing::info!("SubscriptionResponse: {:?}", subscription_confirmation); + } + rhyperliquid::types::ws::SubscriptionResponse::AllMids(ws_all_mids) => { + tracing::info!("AllMids: {:?}", ws_all_mids); + } + rhyperliquid::types::ws::SubscriptionResponse::Candle(ws_candle) => { + tracing::info!("Candle: {:?}", ws_candle); + } + rhyperliquid::types::ws::SubscriptionResponse::Trades(ws_trade) => { + tracing::info!("Trades: {:?}", ws_trade); + } + rhyperliquid::types::ws::SubscriptionResponse::L2Book(ws_book) => { + tracing::info!("L2Book: {:?}", ws_book); + } + rhyperliquid::types::ws::SubscriptionResponse::Notification(ws_notification) => { + tracing::info!("Notification: {:?}", ws_notification); + } + rhyperliquid::types::ws::SubscriptionResponse::WebData3(ws_web_data3) => { + tracing::info!("WebData3: {:?}", ws_web_data3); + } + rhyperliquid::types::ws::SubscriptionResponse::TwapStates(ws_twap_states) => { + tracing::info!("TwapStates: {:?}", ws_twap_states); + } + rhyperliquid::types::ws::SubscriptionResponse::OpenOrders(ws_open_orders) => { + tracing::info!("OpenOrders: {:?}", ws_open_orders); + } + rhyperliquid::types::ws::SubscriptionResponse::UserEvents(ws_user_event) => { + tracing::info!("UserEvents: {:?}", ws_user_event); + } + rhyperliquid::types::ws::SubscriptionResponse::UserNonFundingLedgerUpdates( + ws_user_non_funding_ledger_update, + ) => { + tracing::info!( + "UserNonFundingLedgerUpdate: {:?}", + ws_user_non_funding_ledger_update + ); + } + rhyperliquid::types::ws::SubscriptionResponse::ActiveAssetCtx(ws_asset_ctx) => { + tracing::info!("ActiveAssetCtx: {:?}", ws_asset_ctx); + } + rhyperliquid::types::ws::SubscriptionResponse::ActiveAssetData( + ws_active_asset_data, + ) => { + tracing::info!("ActiveAssetData: {:?}", ws_active_asset_data); + } + rhyperliquid::types::ws::SubscriptionResponse::UserTwapSliceFills( + ws_user_twap_slice_fills, + ) => { + tracing::info!("UserTwapSliceFills: {:?}", ws_user_twap_slice_fills); + } + rhyperliquid::types::ws::SubscriptionResponse::UserTwapHistory( + ws_user_twap_history, + ) => { + tracing::info!("UserTwapHistory: {:?}", ws_user_twap_history); + } + rhyperliquid::types::ws::SubscriptionResponse::Bbo(ws_bbo) => { + tracing::info!("Bbo: {:?}", ws_bbo); + } + rhyperliquid::types::ws::SubscriptionResponse::Pong => tracing::info!("Pong"), + rhyperliquid::types::ws::SubscriptionResponse::ClearinghouseState( + ws_clearinghouse_state, + ) => { + tracing::info!("ClearinghouseState: {:?}", ws_clearinghouse_state); + } + rhyperliquid::types::ws::SubscriptionResponse::UserFills(ws_user_fills) => { + tracing::info!("User Fills: {:?}", ws_user_fills); + } + rhyperliquid::types::ws::SubscriptionResponse::UserFundings(ws_user_fundings) => { + tracing::info!("User Fundings: {:?}", ws_user_fundings); + } } } diff --git a/src/api/subscription/sender.rs b/src/api/subscription/sender.rs index aa3ac81..f79a301 100644 --- a/src/api/subscription/sender.rs +++ b/src/api/subscription/sender.rs @@ -1,5 +1,5 @@ use crate::types::ws::{ - WsActiveAssetData, WsAllMids, WsActiveAssetCtx, WsBbo, WsBook, WsCandle, WsClearinghouseState, + WsActiveAssetCtx, WsActiveAssetData, WsAllMids, WsBbo, WsBook, WsCandle, WsClearinghouseState, WsNotification, WsOpenOrders, WsTrade, WsTwapStates, WsUserEvent, WsUserFills, WsUserFundings, WsUserNonFundingLedgerUpdate, WsUserTwapHistory, WsUserTwapSliceFills, WsWebData3, }; diff --git a/src/api/subscription/ws.rs b/src/api/subscription/ws.rs index 0464940..04bc320 100644 --- a/src/api/subscription/ws.rs +++ b/src/api/subscription/ws.rs @@ -236,15 +236,15 @@ impl SubscriptionClient { let (write_stream_tx, write_stream_rx) = tokio::sync::mpsc::unbounded_channel::(); - let _ = Self::spawn_write_task(write_stream, write_stream_rx); - let _ = Self::spawn_heartbeat(write_stream_tx.clone()); - let _ = Self::spawn_read_task(tx, read_stream); + Self::spawn_write_task(write_stream, write_stream_rx); + Self::spawn_heartbeat(write_stream_tx.clone()); + Self::spawn_read_task(tx, read_stream); Ok(Self { config, events: rx, active_subs: RwLock::new(HashSet::new()), - write_stream_tx: write_stream_tx, + write_stream_tx, }) } @@ -332,7 +332,7 @@ impl SubscriptionClient { channel::(capacity.unwrap_or(1000)) } - async fn send_and_flush(&mut self, confirmation: SubscriptionConfirmation) -> Result<()> { + fn send_and_flush(&self, confirmation: SubscriptionConfirmation) -> Result<()> { self.write_stream_tx .send(StreamMessage::Subscription(confirmation))?; Ok(()) @@ -460,8 +460,7 @@ impl SubscriptionClient { self.send_and_flush(SubscriptionConfirmation { method: "unsubscribe".into(), subscription, - }) - .await?; + })?; self.active_subs.write().await.remove(spec); Ok(()) @@ -499,7 +498,7 @@ impl SubscriptionClient { } } - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -544,7 +543,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -585,7 +584,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -616,7 +615,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -645,7 +644,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -674,7 +673,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -703,7 +702,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -732,7 +731,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -762,7 +761,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); Ok(()) @@ -790,7 +789,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -820,7 +819,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -850,7 +849,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -884,7 +883,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -914,7 +913,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -954,7 +953,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -985,7 +984,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -1016,7 +1015,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); @@ -1047,7 +1046,7 @@ impl SubscriptionClient { subscription, }; - self.send_and_flush(subscription_message).await?; + self.send_and_flush(subscription_message)?; self.active_subs.write().await.insert(spec); diff --git a/src/error.rs b/src/error.rs index 1eab4d0..0fb0625 100644 --- a/src/error.rs +++ b/src/error.rs @@ -67,7 +67,7 @@ pub enum HyperliquidError { impl From> for HyperliquidError { fn from(e: tokio::sync::mpsc::error::SendError) -> Self { - HyperliquidError::WebSocketError(format!("Channel send failed: {}", e)) + Self::WebSocketError(format!("Channel send failed: {}", e)) } } diff --git a/src/types/serialize.rs b/src/types/serialize.rs index 195e6a5..e583851 100644 --- a/src/types/serialize.rs +++ b/src/types/serialize.rs @@ -1,5 +1,4 @@ use serde::Serializer; - use crate::api::request_util::normalize_decimal; /// Serializes decimal values represented as strings to a valid format for the Hyperliquid diff --git a/src/types/ws.rs b/src/types/ws.rs index bf7bd89..a3328f5 100644 --- a/src/types/ws.rs +++ b/src/types/ws.rs @@ -181,7 +181,7 @@ pub struct WsFill { pub struct WsUserFundings { pub is_snapshot: bool, pub user: String, - pub fundings: Vec + pub fundings: Vec, } /// WebSocket user funding payment @@ -262,7 +262,7 @@ pub struct PerpsAssetCtx { pub prev_day_px: Decimal, #[serde(with = "rust_decimal::serde::str")] pub mark_px: Decimal, - #[serde( + #[serde( with = "rust_decimal::serde::str_option", skip_serializing_if = "Option::is_none" )] @@ -291,7 +291,6 @@ pub struct SpotAssetCtx { pub circulating_supply: Decimal, } - /// Leverage information #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -303,7 +302,6 @@ pub struct Leverage { pub value: u32, } - /// WebSocket active asset data for a user #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -311,9 +309,9 @@ pub struct WsActiveAssetData { pub user: String, pub coin: String, pub leverage: Leverage, - #[serde(with = "decimal_array")] + #[serde(with = "decimal_array")] pub max_trade_szs: [Decimal; 2], - #[serde(with = "decimal_array")] + #[serde(with = "decimal_array")] pub available_to_trade: [Decimal; 2], } @@ -532,7 +530,7 @@ pub struct WsNonFundingLedgerUpdate { pub struct WsUserNonFundingLedgerUpdate { pub is_snapshot: bool, pub user: String, - pub non_funding_ledger_updates: Vec + pub non_funding_ledger_updates: Vec, } /// WebSocket ledger update @@ -735,7 +733,6 @@ pub struct WsActiveSpotAssetCtx { pub ctx: SpotAssetCtx, } - /// Identifies a concrete websocket subscription type supported by the feed. /// /// Each variant corresponds to a distinct server-side stream and determines @@ -785,7 +782,7 @@ impl std::fmt::Display for SubscriptionKey { } } -/// Detailed error response from order/cancel operations +/// Error response from order/cancel operations #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WsErrorResponse { @@ -794,7 +791,7 @@ pub struct WsErrorResponse { pub details: Option, } -/// Batch operation error (same length as request) +/// Batch operation error #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BatchError { From 089522435f56affdbf1fb0c3d0f5026698e952b0 Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Fri, 19 Dec 2025 11:30:26 -0500 Subject: [PATCH 8/9] fmt,clippy resolution --- examples/subscriptions.rs | 1 + src/types/serialize.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/subscriptions.rs b/examples/subscriptions.rs index 1fa85fb..7cb3fb3 100644 --- a/examples/subscriptions.rs +++ b/examples/subscriptions.rs @@ -1,3 +1,4 @@ +#![allow(clippy::too_many_lines)] use rhyperliquid::{ example_helpers::{testnet_client, user}, init_tracing::init_tracing, diff --git a/src/types/serialize.rs b/src/types/serialize.rs index e583851..9e678b9 100644 --- a/src/types/serialize.rs +++ b/src/types/serialize.rs @@ -1,5 +1,5 @@ -use serde::Serializer; use crate::api::request_util::normalize_decimal; +use serde::Serializer; /// Serializes decimal values represented as strings to a valid format for the Hyperliquid /// API From dde6942ca3f9a99bf9bae3412e3d0f5f83de405d Mon Sep 17 00:00:00 2001 From: Elijah Hampton Date: Fri, 19 Dec 2025 11:44:35 -0500 Subject: [PATCH 9/9] clippy,fmt resolution --- src/types/info/user.rs | 2 +- src/types/ws.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/info/user.rs b/src/types/info/user.rs index 253ccfb..0ce9880 100644 --- a/src/types/info/user.rs +++ b/src/types/info/user.rs @@ -334,7 +334,7 @@ pub struct Order { pub is_trigger: bool, #[serde(rename = "triggerPx")] pub trigger_px: Decimal, - pub children: Vec, + pub children: Vec, #[serde(rename = "isPositionTpsl")] pub is_position_tpsl: bool, #[serde(rename = "reduceOnly")] diff --git a/src/types/ws.rs b/src/types/ws.rs index a3328f5..59db9df 100644 --- a/src/types/ws.rs +++ b/src/types/ws.rs @@ -181,7 +181,7 @@ pub struct WsFill { pub struct WsUserFundings { pub is_snapshot: bool, pub user: String, - pub fundings: Vec, + pub fundings: Vec, } /// WebSocket user funding payment