rustrade_data/exchange/hyperliquid/
meta.rs1use 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
50const INFO_URL_MAINNET: &str = "https://api.hyperliquid.xyz/info";
52
53const INFO_URL_TESTNET: &str = "https://api.hyperliquid-testnet.xyz/info";
55
56const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
58
59#[derive(Debug, thiserror::Error)]
61#[non_exhaustive]
62pub enum HyperliquidMetaError {
63 #[error("Hyperliquid info request failed: {0}")]
65 Http(#[from] reqwest::Error),
66
67 #[error("Hyperliquid lists no builder deployer named {0:?}")]
70 UnknownDeployer(SmolStr),
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct HyperliquidMeta {
77 perps: Perps,
78 spot_pairs: SpotPairs,
79}
80
81impl HyperliquidMeta {
82 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 async fn fetch_from(url: &str, deployers: &[&str]) -> Result<Self, HyperliquidMetaError> {
106 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 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 pub fn new(perps: Perps, spot_pairs: SpotPairs) -> Self {
149 Self { perps, spot_pairs }
150 }
151
152 pub fn perp_coin(&self, name: &str) -> Option<&PerpCoin> {
156 self.perps.get(name)
157 }
158
159 pub fn spot_pair(&self, base: &str, quote: &str) -> Option<&SpotPair> {
162 self.spot_pairs.find(base, quote)
163 }
164
165 pub fn perps(&self) -> &Perps {
167 &self.perps
168 }
169
170 pub fn spot_pairs(&self) -> &SpotPairs {
172 &self.spot_pairs
173 }
174}
175
176async 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#[allow(clippy::unwrap_used)]
195mod tests {
196 use super::*;
197 use wiremock::{
198 Mock, MockServer, ResponseTemplate,
199 matchers::{body_json, method},
200 };
201
202 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] 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}