use rustrade_instrument::hyperliquid::{Network, PerpCoin, Perps, SpotPair, SpotPairs};
use serde::de::DeserializeOwned;
use serde_json::json;
use smol_str::SmolStr;
use std::time::Duration;
use tracing::debug;
const INFO_URL_MAINNET: &str = "https://api.hyperliquid.xyz/info";
const INFO_URL_TESTNET: &str = "https://api.hyperliquid-testnet.xyz/info";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum HyperliquidMetaError {
#[error("Hyperliquid info request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Hyperliquid lists no builder deployer named {0:?}")]
UnknownDeployer(SmolStr),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HyperliquidMeta {
perps: Perps,
spot_pairs: SpotPairs,
}
impl HyperliquidMeta {
pub async fn fetch(network: Network, deployers: &[&str]) -> Result<Self, HyperliquidMetaError> {
let url = match network {
Network::Mainnet => INFO_URL_MAINNET,
Network::Testnet => INFO_URL_TESTNET,
};
Self::fetch_from(url, deployers).await
}
async fn fetch_from(url: &str, deployers: &[&str]) -> Result<Self, HyperliquidMetaError> {
if let Some(&malformed) = deployers.iter().find(|deployer| {
deployer.is_empty() || !deployer.bytes().all(|byte| byte.is_ascii_alphanumeric())
}) {
return Err(HyperliquidMetaError::UnknownDeployer(malformed.into()));
}
let client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()?;
let deployer_perps = futures::future::try_join_all(deployers.iter().map(|&deployer| {
let client = &client;
async move {
info::<Option<Perps>>(client, url, json!({"type": "meta", "dex": deployer}))
.await?
.ok_or_else(|| HyperliquidMetaError::UnknownDeployer(deployer.into()))
}
}));
let (perps, spot_pairs, deployer_perps) = futures::try_join!(
info::<Perps>(&client, url, json!({"type": "meta"})),
info::<SpotPairs>(&client, url, json!({"type": "spotMeta"})),
deployer_perps,
)?;
let meta = Self::new(
std::iter::once(perps).chain(deployer_perps).collect(),
spot_pairs,
);
debug!(
perps = meta.perps.len(),
spot_pairs = meta.spot_pairs.len(),
?deployers,
"read Hyperliquid market metadata"
);
Ok(meta)
}
pub fn new(perps: Perps, spot_pairs: SpotPairs) -> Self {
Self { perps, spot_pairs }
}
pub fn perp_coin(&self, name: &str) -> Option<&PerpCoin> {
self.perps.get(name)
}
pub fn spot_pair(&self, base: &str, quote: &str) -> Option<&SpotPair> {
self.spot_pairs.find(base, quote)
}
pub fn perps(&self) -> &Perps {
&self.perps
}
pub fn spot_pairs(&self) -> &SpotPairs {
&self.spot_pairs
}
}
async fn info<T: DeserializeOwned>(
client: &reqwest::Client,
url: &str,
request: serde_json::Value,
) -> Result<T, HyperliquidMetaError> {
Ok(client
.post(url)
.json(&request)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_json, method},
};
const META: &str =
r#"{"universe": [{"name": "BTC", "szDecimals": 5}, {"name": "kPEPE", "szDecimals": 0}]}"#;
const META_XYZ: &str = r#"{"universe": [{"name": "xyz:TSLA", "szDecimals": 3}]}"#;
const SPOT_META: &str = r#"{
"universe": [
{"name": "PURR/USDC", "tokens": [1, 0], "index": 0},
{"name": "@107", "tokens": [150, 0], "index": 107}
],
"tokens": [
{"name": "USDC", "szDecimals": 8, "index": 0},
{"name": "PURR", "szDecimals": 0, "index": 1},
{"name": "HYPE", "szDecimals": 2, "index": 150}
]
}"#;
async fn info_server() -> MockServer {
let server = MockServer::start().await;
for (request, response) in [
(json!({"type": "meta"}), META),
(json!({"type": "meta", "dex": "xyz"}), META_XYZ),
(json!({"type": "meta", "dex": "nope"}), "null"),
(json!({"type": "spotMeta"}), SPOT_META),
] {
Mock::given(method("POST"))
.and(body_json(request))
.respond_with(ResponseTemplate::new(200).set_body_raw(response, "application/json"))
.mount(&server)
.await;
}
server
}
#[tokio::test]
async fn fetch_reads_the_default_perps_the_named_deployers_and_spot() {
let server = info_server().await;
let meta = HyperliquidMeta::fetch_from(&server.uri(), &["xyz"])
.await
.unwrap();
assert_eq!(meta.perps().len(), 3);
assert_eq!(meta.perp_coin("KPEPE").unwrap().coin(), "kPEPE");
assert_eq!(meta.perp_coin("BTC").unwrap().sz_decimals(), 5);
assert_eq!(meta.perp_coin("xyz:TSLA").unwrap().sz_decimals(), 3);
assert_eq!(meta.perp_coin("xyz:TSLA").unwrap().deployer(), Some("xyz"));
assert_eq!(meta.spot_pair("hype", "usdc").unwrap().coin(), "@107");
assert_eq!(meta.spot_pair("PURR", "USDC").unwrap().coin(), "PURR/USDC");
assert_eq!(meta.spot_pairs().len(), 2);
}
#[tokio::test]
async fn fetch_reads_no_deployer_unless_named() {
let server = info_server().await;
let meta = HyperliquidMeta::fetch_from(&server.uri(), &[])
.await
.unwrap();
assert_eq!(meta.perps().len(), 2);
assert_eq!(meta.perp_coin("xyz:TSLA"), None);
}
#[tokio::test]
async fn fetch_rejects_a_deployer_hyperliquid_does_not_list() {
let server = info_server().await;
for deployer in ["nope", "", "xyz:"] {
let error = HyperliquidMeta::fetch_from(&server.uri(), &["xyz", deployer])
.await
.unwrap_err();
assert!(
matches!(&error, HyperliquidMetaError::UnknownDeployer(name) if name == deployer),
"{deployer:?}: {error:?}"
);
}
}
#[tokio::test]
async fn fetch_sends_no_request_for_a_malformed_deployer() {
let server = MockServer::start().await;
let error = HyperliquidMeta::fetch_from(&server.uri(), &["xyz", "a-b"])
.await
.unwrap_err();
assert!(
matches!(&error, HyperliquidMetaError::UnknownDeployer(name) if name == "a-b"),
"{error:?}"
);
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn fetch_fails_on_a_response_that_is_not_the_metadata() {
for body in ["null", "{}"] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_json(json!({"type": "meta"})))
.respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(body_json(json!({"type": "spotMeta"})))
.respond_with(
ResponseTemplate::new(200).set_body_raw(SPOT_META, "application/json"),
)
.mount(&server)
.await;
let error = HyperliquidMeta::fetch_from(&server.uri(), &[])
.await
.unwrap_err();
assert!(
matches!(error, HyperliquidMetaError::Http(_)),
"{body}: {error:?}"
);
}
}
#[tokio::test]
async fn fetch_fails_on_an_error_status() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(429))
.mount(&server)
.await;
let error = HyperliquidMeta::fetch_from(&server.uri(), &[])
.await
.unwrap_err();
assert!(matches!(error, HyperliquidMetaError::Http(_)), "{error:?}");
}
#[tokio::test]
#[ignore] async fn fetch_reads_mainnet() {
let meta = HyperliquidMeta::fetch(Network::Mainnet, &["xyz"])
.await
.unwrap();
assert_eq!(meta.perp_coin("KPEPE").unwrap().coin(), "kPEPE");
assert_eq!(meta.perp_coin("xyz:TSLA").unwrap().coin(), "xyz:TSLA");
assert_eq!(meta.spot_pair("HYPE", "USDC").unwrap().coin(), "@107");
assert_eq!(meta.spot_pair("PURR", "USDC").unwrap().coin(), "PURR/USDC");
}
}