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 = 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] 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}