Skip to main content

rustrade_data/exchange/hyperliquid/
meta.rs

1//! Hyperliquid's market metadata: the coin each perpetual and spot pair is subscribed by.
2//!
3//! A Hyperliquid subscription names its market by a coin, exactly as Hyperliquid spells it, and
4//! a wrong coin gets no data. Worse, the server closes the connection, ending every other
5//! subscription on it too. [`HyperliquidMeta`] reads the coins from Hyperliquid's info endpoint
6//! and finds them by name:
7//!
8//! - [`HyperliquidMeta::perp_coin`] takes a perpetual's name in any ASCII case and returns the
9//!   venue's spelling (`KPEPE` finds `kPEPE`).
10//! - [`HyperliquidMeta::spot_pair`] takes a pair's base and quote tokens (`HYPE`, `USDC`) and
11//!   returns its coin (`@107`).
12//!
13//! Build the subscription's instrument from the result with
14//! [`MarketInstrumentData::hyperliquid_perp`](crate::instrument::MarketInstrumentData::hyperliquid_perp)
15//! or
16//! [`MarketInstrumentData::hyperliquid_spot`](crate::instrument::MarketInstrumentData::hyperliquid_spot).
17//!
18//! ```ignore
19//! use rustrade_data::{
20//!     exchange::hyperliquid::{HyperliquidMeta, Network},
21//!     instrument::MarketInstrumentData,
22//! };
23//!
24//! // The default perpetuals and spot pairs, plus builder deployer `xyz`'s perpetuals.
25//! let meta = HyperliquidMeta::fetch(Network::Mainnet, &["xyz"]).await?;
26//!
27//! let pepe = meta.perp_coin("KPEPE").ok_or("no such perpetual")?;
28//! let tsla = meta.perp_coin("xyz:TSLA").ok_or("no such perpetual")?;
29//! let hype = meta.spot_pair("HYPE", "USDC").ok_or("no such pair")?;
30//!
31//! let perps = [
32//!     MarketInstrumentData::hyperliquid_perp(0, pepe),
33//!     MarketInstrumentData::hyperliquid_perp(1, tsla),
34//! ];
35//! let spot = MarketInstrumentData::hyperliquid_spot(2, hype);
36//! ```
37//!
38//! # Snapshot
39//!
40//! A [`HyperliquidMeta`] is what the info endpoint listed when it was read. A market listed
41//! since is missing from it, so read it again to find one.
42
43use rustrade_instrument::hyperliquid::{Network, PerpCoin, Perps, SpotPair, SpotPairs};
44use serde::de::DeserializeOwned;
45use serde_json::json;
46use smol_str::SmolStr;
47use std::time::Duration;
48use tracing::debug;
49
50/// Hyperliquid's mainnet info endpoint.
51const INFO_URL_MAINNET: &str = "https://api.hyperliquid.xyz/info";
52
53/// Hyperliquid's testnet info endpoint.
54const INFO_URL_TESTNET: &str = "https://api.hyperliquid-testnet.xyz/info";
55
56/// How long one info request may take before [`HyperliquidMeta::fetch`] gives up on it.
57const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
58
59/// Why [`HyperliquidMeta::fetch`] failed.
60#[derive(Debug, thiserror::Error)]
61#[non_exhaustive]
62pub enum HyperliquidMetaError {
63    /// An info request failed, timed out, or returned a body that is not the expected response.
64    #[error("Hyperliquid info request failed: {0}")]
65    Http(#[from] reqwest::Error),
66
67    /// Hyperliquid lists no builder deployer by this name. A name that is not one or more ASCII
68    /// letters and digits, as every deployer's is, is rejected before any request is sent.
69    #[error("Hyperliquid lists no builder deployer named {0:?}")]
70    UnknownDeployer(SmolStr),
71}
72
73/// Hyperliquid's perpetuals and spot pairs, read from its info endpoint. See the
74/// [module docs](self).
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct HyperliquidMeta {
77    perps: Perps,
78    spot_pairs: SpotPairs,
79}
80
81impl HyperliquidMeta {
82    /// Read the perpetuals and spot pairs `network` lists.
83    ///
84    /// The perpetuals are the default ones plus those of each builder deployer (HIP-3) named in
85    /// `deployers` (such as `xyz`), each named in ASCII letters and digits. Hyperliquid has a
86    /// `meta` response per deployer, so only the deployers named are read. The requests run
87    /// concurrently, each abandoned after 10 seconds.
88    ///
89    /// The data streams connect to mainnet only, so a testnet read serves other uses, such as
90    /// orders on testnet.
91    ///
92    /// # Errors
93    ///
94    /// [`HyperliquidMetaError::UnknownDeployer`] if Hyperliquid lists no deployer of one of
95    /// those names, and [`HyperliquidMetaError::Http`] if a request fails.
96    pub async fn fetch(network: Network, deployers: &[&str]) -> Result<Self, HyperliquidMetaError> {
97        let url = match network {
98            Network::Mainnet => INFO_URL_MAINNET,
99            Network::Testnet => INFO_URL_TESTNET,
100        };
101        Self::fetch_from(url, deployers).await
102    }
103
104    /// [`Self::fetch`] from the info endpoint at `url`.
105    async fn fetch_from(url: &str, deployers: &[&str]) -> Result<Self, HyperliquidMetaError> {
106        // Checked before any request: an empty or malformed name would read some other `meta`,
107        // as an empty `dex` reads the default perpetuals'.
108        if let Some(&malformed) = deployers.iter().find(|deployer| {
109            deployer.is_empty() || !deployer.bytes().all(|byte| byte.is_ascii_alphanumeric())
110        }) {
111            return Err(HyperliquidMetaError::UnknownDeployer(malformed.into()));
112        }
113
114        let client = reqwest::Client::builder()
115            .timeout(REQUEST_TIMEOUT)
116            .build()?;
117
118        let deployer_perps = futures::future::try_join_all(deployers.iter().map(|&deployer| {
119            let client = &client;
120            async move {
121                // Hyperliquid answers `null` for a deployer it does not list.
122                info::<Option<Perps>>(client, url, json!({"type": "meta", "dex": deployer}))
123                    .await?
124                    .ok_or_else(|| HyperliquidMetaError::UnknownDeployer(deployer.into()))
125            }
126        }));
127        let (perps, spot_pairs, deployer_perps) = futures::try_join!(
128            info::<Perps>(&client, url, json!({"type": "meta"})),
129            info::<SpotPairs>(&client, url, json!({"type": "spotMeta"})),
130            deployer_perps,
131        )?;
132
133        let meta = Self::new(
134            std::iter::once(perps).chain(deployer_perps).collect(),
135            spot_pairs,
136        );
137        debug!(
138            perps = meta.perps.len(),
139            spot_pairs = meta.spot_pairs.len(),
140            ?deployers,
141            "read Hyperliquid market metadata"
142        );
143        Ok(meta)
144    }
145
146    /// Market metadata from perpetuals and spot pairs already read, such as from a stored
147    /// response.
148    pub fn new(perps: Perps, spot_pairs: SpotPairs) -> Self {
149        Self { perps, spot_pairs }
150    }
151
152    /// The perpetual named `name`, in the venue's spelling or differing from it only in ASCII
153    /// case. A builder-deployed perpetual is named with its deployer (`xyz:TSLA`), and is found
154    /// only if that deployer was read. See [`Perps::get`].
155    pub fn perp_coin(&self, name: &str) -> Option<&PerpCoin> {
156        self.perps.get(name)
157    }
158
159    /// The spot pair of `base` quoted in `quote`, named by their tokens in the venue's spelling
160    /// or differing from it only in ASCII case. See [`SpotPairs::find`].
161    pub fn spot_pair(&self, base: &str, quote: &str) -> Option<&SpotPair> {
162        self.spot_pairs.find(base, quote)
163    }
164
165    /// Every perpetual read.
166    pub fn perps(&self) -> &Perps {
167        &self.perps
168    }
169
170    /// Every spot pair read.
171    pub fn spot_pairs(&self) -> &SpotPairs {
172        &self.spot_pairs
173    }
174}
175
176/// Post `request` to the info endpoint at `url` and parse the response.
177async fn info<T: DeserializeOwned>(
178    client: &reqwest::Client,
179    url: &str,
180    request: serde_json::Value,
181) -> Result<T, HyperliquidMetaError> {
182    Ok(client
183        .post(url)
184        .json(&request)
185        .send()
186        .await?
187        .error_for_status()?
188        .json()
189        .await?)
190}
191
192#[cfg(test)]
193// Test code: panics on bad input are acceptable
194#[allow(clippy::unwrap_used)]
195mod tests {
196    use super::*;
197    use wiremock::{
198        Mock, MockServer, ResponseTemplate,
199        matchers::{body_json, method},
200    };
201
202    /// Info responses in Hyperliquid's shape, with synthetic values.
203    const META: &str = r#"{"universe": [{"name": "BTC"}, {"name": "kPEPE"}]}"#;
204    const META_XYZ: &str = r#"{"universe": [{"name": "xyz:TSLA"}]}"#;
205    const SPOT_META: &str = r#"{
206        "universe": [
207            {"name": "PURR/USDC", "tokens": [1, 0], "index": 0},
208            {"name": "@107", "tokens": [150, 0], "index": 107}
209        ],
210        "tokens": [
211            {"name": "USDC", "szDecimals": 8, "index": 0},
212            {"name": "PURR", "szDecimals": 0, "index": 1},
213            {"name": "HYPE", "szDecimals": 2, "index": 150}
214        ]
215    }"#;
216
217    async fn info_server() -> MockServer {
218        let server = MockServer::start().await;
219        for (request, response) in [
220            (json!({"type": "meta"}), META),
221            (json!({"type": "meta", "dex": "xyz"}), META_XYZ),
222            (json!({"type": "meta", "dex": "nope"}), "null"),
223            (json!({"type": "spotMeta"}), SPOT_META),
224        ] {
225            Mock::given(method("POST"))
226                .and(body_json(request))
227                .respond_with(ResponseTemplate::new(200).set_body_raw(response, "application/json"))
228                .mount(&server)
229                .await;
230        }
231        server
232    }
233
234    #[tokio::test]
235    async fn fetch_reads_the_default_perps_the_named_deployers_and_spot() {
236        let server = info_server().await;
237
238        let meta = HyperliquidMeta::fetch_from(&server.uri(), &["xyz"])
239            .await
240            .unwrap();
241
242        assert_eq!(meta.perps().len(), 3);
243        assert_eq!(meta.perp_coin("KPEPE").unwrap().coin(), "kPEPE");
244        assert_eq!(meta.perp_coin("xyz:TSLA").unwrap().deployer(), Some("xyz"));
245        assert_eq!(meta.spot_pair("hype", "usdc").unwrap().coin(), "@107");
246        assert_eq!(meta.spot_pair("PURR", "USDC").unwrap().coin(), "PURR/USDC");
247        assert_eq!(meta.spot_pairs().len(), 2);
248    }
249
250    #[tokio::test]
251    async fn fetch_reads_no_deployer_unless_named() {
252        let server = info_server().await;
253
254        let meta = HyperliquidMeta::fetch_from(&server.uri(), &[])
255            .await
256            .unwrap();
257
258        assert_eq!(meta.perps().len(), 2);
259        assert_eq!(meta.perp_coin("xyz:TSLA"), None);
260    }
261
262    #[tokio::test]
263    async fn fetch_rejects_a_deployer_hyperliquid_does_not_list() {
264        let server = info_server().await;
265
266        for deployer in ["nope", "", "xyz:"] {
267            let error = HyperliquidMeta::fetch_from(&server.uri(), &["xyz", deployer])
268                .await
269                .unwrap_err();
270            assert!(
271                matches!(&error, HyperliquidMetaError::UnknownDeployer(name) if name == deployer),
272                "{deployer:?}: {error:?}"
273            );
274        }
275    }
276
277    #[tokio::test]
278    async fn fetch_sends_no_request_for_a_malformed_deployer() {
279        let server = MockServer::start().await;
280
281        let error = HyperliquidMeta::fetch_from(&server.uri(), &["xyz", "a-b"])
282            .await
283            .unwrap_err();
284
285        assert!(
286            matches!(&error, HyperliquidMetaError::UnknownDeployer(name) if name == "a-b"),
287            "{error:?}"
288        );
289        assert!(server.received_requests().await.unwrap().is_empty());
290    }
291
292    #[tokio::test]
293    async fn fetch_fails_on_a_response_that_is_not_the_metadata() {
294        for body in ["null", "{}"] {
295            let server = MockServer::start().await;
296            Mock::given(method("POST"))
297                .and(body_json(json!({"type": "meta"})))
298                .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
299                .mount(&server)
300                .await;
301            Mock::given(method("POST"))
302                .and(body_json(json!({"type": "spotMeta"})))
303                .respond_with(
304                    ResponseTemplate::new(200).set_body_raw(SPOT_META, "application/json"),
305                )
306                .mount(&server)
307                .await;
308
309            let error = HyperliquidMeta::fetch_from(&server.uri(), &[])
310                .await
311                .unwrap_err();
312
313            assert!(
314                matches!(error, HyperliquidMetaError::Http(_)),
315                "{body}: {error:?}"
316            );
317        }
318    }
319
320    #[tokio::test]
321    async fn fetch_fails_on_an_error_status() {
322        let server = MockServer::start().await;
323        Mock::given(method("POST"))
324            .respond_with(ResponseTemplate::new(429))
325            .mount(&server)
326            .await;
327
328        let error = HyperliquidMeta::fetch_from(&server.uri(), &[])
329            .await
330            .unwrap_err();
331
332        assert!(matches!(error, HyperliquidMetaError::Http(_)), "{error:?}");
333    }
334
335    #[tokio::test]
336    #[ignore] // Requires network
337    async fn fetch_reads_mainnet() {
338        let meta = HyperliquidMeta::fetch(Network::Mainnet, &["xyz"])
339            .await
340            .unwrap();
341
342        assert_eq!(meta.perp_coin("KPEPE").unwrap().coin(), "kPEPE");
343        assert_eq!(meta.perp_coin("xyz:TSLA").unwrap().coin(), "xyz:TSLA");
344        assert_eq!(meta.spot_pair("HYPE", "USDC").unwrap().coin(), "@107");
345        assert_eq!(meta.spot_pair("PURR", "USDC").unwrap().coin(), "PURR/USDC");
346    }
347}