use reqwest::Client;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::OnceCell;
use tracing::debug;
const INFO_URL: &str = "https://api.hyperliquid.xyz/info";
const INFO_URL_TESTNET: &str = "https://api.hyperliquid-testnet.xyz/info";
#[derive(Debug, thiserror::Error)]
pub enum SpotMetaError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Token not found: {0}")]
TokenNotFound(String),
#[error("Pair not found: {0}/{1}")]
PairNotFound(String, String),
}
#[derive(Debug, Deserialize)]
struct SpotMetaResponse {
tokens: Vec<TokenInfo>,
universe: Vec<UniverseEntry>,
}
#[derive(Debug, Deserialize)]
struct TokenInfo {
name: String,
index: u32,
}
#[derive(Debug, Deserialize)]
struct UniverseEntry {
#[allow(dead_code)]
name: String,
index: u32,
tokens: Vec<u32>,
}
#[derive(Debug, Clone)]
struct SpotMetaCache {
token_indices: HashMap<String, u32>,
pair_indices: HashMap<(u32, u32), u32>,
}
impl SpotMetaCache {
fn from_response(response: SpotMetaResponse) -> Self {
let token_indices: HashMap<String, u32> = response
.tokens
.into_iter()
.map(|t| (t.name.to_uppercase(), t.index))
.collect();
let pair_indices: HashMap<(u32, u32), u32> = response
.universe
.into_iter()
.filter_map(|u| {
if u.tokens.len() == 2 {
Some(((u.tokens[0], u.tokens[1]), u.index))
} else {
None
}
})
.collect();
Self {
token_indices,
pair_indices,
}
}
fn resolve(&self, base: &str, quote: &str) -> Result<String, SpotMetaError> {
let base_upper = base.to_uppercase();
let quote_upper = quote.to_uppercase();
let base_idx = self
.token_indices
.get(&base_upper)
.ok_or_else(|| SpotMetaError::TokenNotFound(base_upper.clone()))?;
let quote_idx = self
.token_indices
.get("e_upper)
.ok_or_else(|| SpotMetaError::TokenNotFound(quote_upper.clone()))?;
let pair_idx = self
.pair_indices
.get(&(*base_idx, *quote_idx))
.or_else(|| self.pair_indices.get(&(*quote_idx, *base_idx)))
.ok_or_else(|| SpotMetaError::PairNotFound(base_upper, quote_upper))?;
Ok(format!("@{}", pair_idx))
}
}
#[derive(Debug, Clone)]
pub struct SpotMetaResolver {
client: Client,
cache: Arc<OnceCell<Arc<SpotMetaCache>>>,
testnet: bool,
}
impl Default for SpotMetaResolver {
fn default() -> Self {
Self::new(false)
}
}
impl SpotMetaResolver {
fn new(testnet: bool) -> Self {
Self {
client: Client::new(),
cache: Arc::new(OnceCell::new()),
testnet,
}
}
pub fn mainnet() -> Self {
Self::new(false)
}
pub fn testnet() -> Self {
Self::new(true)
}
fn info_url(&self) -> &'static str {
if self.testnet {
INFO_URL_TESTNET
} else {
INFO_URL
}
}
async fn fetch(&self) -> Result<Arc<SpotMetaCache>, SpotMetaError> {
let response: SpotMetaResponse = self
.client
.post(self.info_url())
.json(&serde_json::json!({"type": "spotMeta"}))
.send()
.await?
.json()
.await?;
let cache = SpotMetaCache::from_response(response);
debug!(
tokens = cache.token_indices.len(),
pairs = cache.pair_indices.len(),
"Fetched spot metadata"
);
Ok(Arc::new(cache))
}
pub async fn resolve(&self, base: &str, quote: &str) -> Result<String, SpotMetaError> {
let cache = self.cache.get_or_try_init(|| self.fetch()).await?;
cache.resolve(base, quote)
}
pub fn pair_exists(&self, base: &str, quote: &str) -> Option<bool> {
self.cache
.get()
.map(|cache| cache.resolve(base, quote).is_ok())
}
}
static MAINNET_RESOLVER: std::sync::OnceLock<SpotMetaResolver> = std::sync::OnceLock::new();
pub fn mainnet_resolver() -> &'static SpotMetaResolver {
MAINNET_RESOLVER.get_or_init(SpotMetaResolver::mainnet)
}
pub async fn resolve_spot_pair(base: &str, quote: &str) -> Result<String, SpotMetaError> {
mainnet_resolver().resolve(base, quote).await
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[tokio::test]
#[ignore] async fn test_resolve_hype_usdc() {
let resolver = SpotMetaResolver::mainnet();
let result = resolver.resolve("hype", "usdc").await.unwrap();
assert_eq!(result, "@107");
}
#[tokio::test]
#[ignore] async fn test_resolve_purr_usdc() {
let resolver = SpotMetaResolver::mainnet();
let result = resolver.resolve("purr", "usdc").await.unwrap();
assert_eq!(result, "@0");
}
#[tokio::test]
#[ignore] async fn test_resolve_case_insensitive() {
let resolver = SpotMetaResolver::mainnet();
let r1 = resolver.resolve("HYPE", "USDC").await.unwrap();
let r2 = resolver.resolve("hype", "usdc").await.unwrap();
let r3 = resolver.resolve("Hype", "Usdc").await.unwrap();
assert_eq!(r1, "@107");
assert_eq!(r2, "@107");
assert_eq!(r3, "@107");
}
#[tokio::test]
#[ignore] async fn test_resolve_invalid_token() {
let resolver = SpotMetaResolver::mainnet();
let result = resolver.resolve("NOTAREAL", "usdc").await;
assert!(matches!(result, Err(SpotMetaError::TokenNotFound(_))));
}
}