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 =
204        r#"{"universe": [{"name": "BTC", "szDecimals": 5}, {"name": "kPEPE", "szDecimals": 0}]}"#;
205    const META_XYZ: &str = r#"{"universe": [{"name": "xyz:TSLA", "szDecimals": 3}]}"#;
206    const SPOT_META: &str = r#"{
207        "universe": [
208            {"name": "PURR/USDC", "tokens": [1, 0], "index": 0},
209            {"name": "@107", "tokens": [150, 0], "index": 107}
210        ],
211        "tokens": [
212            {"name": "USDC", "szDecimals": 8, "index": 0},
213            {"name": "PURR", "szDecimals": 0, "index": 1},
214            {"name": "HYPE", "szDecimals": 2, "index": 150}
215        ]
216    }"#;
217
218    async fn info_server() -> MockServer {
219        let server = MockServer::start().await;
220        for (request, response) in [
221            (json!({"type": "meta"}), META),
222            (json!({"type": "meta", "dex": "xyz"}), META_XYZ),
223            (json!({"type": "meta", "dex": "nope"}), "null"),
224            (json!({"type": "spotMeta"}), SPOT_META),
225        ] {
226            Mock::given(method("POST"))
227                .and(body_json(request))
228                .respond_with(ResponseTemplate::new(200).set_body_raw(response, "application/json"))
229                .mount(&server)
230                .await;
231        }
232        server
233    }
234
235    #[tokio::test]
236    async fn fetch_reads_the_default_perps_the_named_deployers_and_spot() {
237        let server = info_server().await;
238
239        let meta = HyperliquidMeta::fetch_from(&server.uri(), &["xyz"])
240            .await
241            .unwrap();
242
243        assert_eq!(meta.perps().len(), 3);
244        assert_eq!(meta.perp_coin("KPEPE").unwrap().coin(), "kPEPE");
245        assert_eq!(meta.perp_coin("BTC").unwrap().sz_decimals(), 5);
246        assert_eq!(meta.perp_coin("xyz:TSLA").unwrap().sz_decimals(), 3);
247        assert_eq!(meta.perp_coin("xyz:TSLA").unwrap().deployer(), Some("xyz"));
248        assert_eq!(meta.spot_pair("hype", "usdc").unwrap().coin(), "@107");
249        assert_eq!(meta.spot_pair("PURR", "USDC").unwrap().coin(), "PURR/USDC");
250        assert_eq!(meta.spot_pairs().len(), 2);
251    }
252
253    #[tokio::test]
254    async fn fetch_reads_no_deployer_unless_named() {
255        let server = info_server().await;
256
257        let meta = HyperliquidMeta::fetch_from(&server.uri(), &[])
258            .await
259            .unwrap();
260
261        assert_eq!(meta.perps().len(), 2);
262        assert_eq!(meta.perp_coin("xyz:TSLA"), None);
263    }
264
265    #[tokio::test]
266    async fn fetch_rejects_a_deployer_hyperliquid_does_not_list() {
267        let server = info_server().await;
268
269        for deployer in ["nope", "", "xyz:"] {
270            let error = HyperliquidMeta::fetch_from(&server.uri(), &["xyz", deployer])
271                .await
272                .unwrap_err();
273            assert!(
274                matches!(&error, HyperliquidMetaError::UnknownDeployer(name) if name == deployer),
275                "{deployer:?}: {error:?}"
276            );
277        }
278    }
279
280    #[tokio::test]
281    async fn fetch_sends_no_request_for_a_malformed_deployer() {
282        let server = MockServer::start().await;
283
284        let error = HyperliquidMeta::fetch_from(&server.uri(), &["xyz", "a-b"])
285            .await
286            .unwrap_err();
287
288        assert!(
289            matches!(&error, HyperliquidMetaError::UnknownDeployer(name) if name == "a-b"),
290            "{error:?}"
291        );
292        assert!(server.received_requests().await.unwrap().is_empty());
293    }
294
295    #[tokio::test]
296    async fn fetch_fails_on_a_response_that_is_not_the_metadata() {
297        for body in ["null", "{}"] {
298            let server = MockServer::start().await;
299            Mock::given(method("POST"))
300                .and(body_json(json!({"type": "meta"})))
301                .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
302                .mount(&server)
303                .await;
304            Mock::given(method("POST"))
305                .and(body_json(json!({"type": "spotMeta"})))
306                .respond_with(
307                    ResponseTemplate::new(200).set_body_raw(SPOT_META, "application/json"),
308                )
309                .mount(&server)
310                .await;
311
312            let error = HyperliquidMeta::fetch_from(&server.uri(), &[])
313                .await
314                .unwrap_err();
315
316            assert!(
317                matches!(error, HyperliquidMetaError::Http(_)),
318                "{body}: {error:?}"
319            );
320        }
321    }
322
323    #[tokio::test]
324    async fn fetch_fails_on_an_error_status() {
325        let server = MockServer::start().await;
326        Mock::given(method("POST"))
327            .respond_with(ResponseTemplate::new(429))
328            .mount(&server)
329            .await;
330
331        let error = HyperliquidMeta::fetch_from(&server.uri(), &[])
332            .await
333            .unwrap_err();
334
335        assert!(matches!(error, HyperliquidMetaError::Http(_)), "{error:?}");
336    }
337
338    #[tokio::test]
339    #[ignore] // Requires network
340    async fn fetch_reads_mainnet() {
341        let meta = HyperliquidMeta::fetch(Network::Mainnet, &["xyz"])
342            .await
343            .unwrap();
344
345        assert_eq!(meta.perp_coin("KPEPE").unwrap().coin(), "kPEPE");
346        assert_eq!(meta.perp_coin("xyz:TSLA").unwrap().coin(), "xyz:TSLA");
347        assert_eq!(meta.spot_pair("HYPE", "USDC").unwrap().coin(), "@107");
348        assert_eq!(meta.spot_pair("PURR", "USDC").unwrap().coin(), "PURR/USDC");
349    }
350}