use super::common::info;
use super::error::HyperliquidConnectError;
use hyperliquid_rust_sdk::InfoClient;
use rustrade_instrument::{hyperliquid::CoinKind, instrument::name::InstrumentNameExchange};
use serde::Deserialize;
use smol_str::{SmolStr, format_smolstr};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use tracing::warn;
pub(super) const DEFAULT_COLLATERAL: &str = "USDC";
const HIP3_ASSET_BASE: u32 = 100_000;
const HIP3_ASSETS_PER_DEX: u32 = 10_000;
#[derive(Debug, Default)]
pub(super) struct PerpDexes {
collateral: BTreeMap<SmolStr, SmolStr>,
asset_ids: HashMap<String, u32>,
sz_decimals: HashMap<String, u32>,
}
#[derive(Debug, Deserialize)]
struct ListedDex {
name: SmolStr,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DexMeta {
universe: Vec<DexPerp>,
collateral_token: usize,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DexPerp {
name: String,
sz_decimals: u32,
}
#[derive(Debug, Deserialize)]
struct SpotTokens {
tokens: Vec<SpotToken>,
}
#[derive(Debug, Deserialize)]
struct SpotToken {
name: SmolStr,
index: usize,
}
impl PerpDexes {
pub(super) async fn fetch(
info_client: &InfoClient,
dexes: &[SmolStr],
) -> Result<Self, HyperliquidConnectError> {
if dexes.is_empty() {
return Ok(Self::default());
}
let (listed, spot) = tokio::try_join!(
info::<Vec<Option<ListedDex>>>(
info_client,
"perpDexs",
r#"{"type":"perpDexs"}"#.to_owned()
),
info::<SpotTokens>(info_client, "spotMeta", r#"{"type":"spotMeta"}"#.to_owned()),
)?;
let dexes = dexes.iter().collect::<BTreeSet<_>>();
let indexed = dexes
.into_iter()
.map(|dex| {
let index = listed
.iter()
.position(|listed| listed.as_ref().is_some_and(|listed| listed.name == *dex))
.ok_or_else(|| HyperliquidConnectError::UnknownDex(dex.clone()))?;
Ok((dex, index))
})
.collect::<Result<Vec<_>, HyperliquidConnectError>>()?;
let metas = futures::future::try_join_all(indexed.iter().map(|(dex, _)| {
info::<Option<DexMeta>>(
info_client,
"meta",
serde_json::json!({"type": "meta", "dex": dex}).to_string(),
)
}))
.await?;
let tokens = spot
.tokens
.into_iter()
.map(|token| (token.index, token.name))
.collect::<HashMap<_, _>>();
let mut perp_dexes = Self::default();
for ((dex, dex_index), meta) in indexed.into_iter().zip(metas) {
let meta = meta.ok_or_else(|| {
HyperliquidConnectError::Metadata(format!("no meta for listed DEX {dex:?}"))
})?;
perp_dexes.insert(dex, dex_index, meta, &tokens)?;
}
Ok(perp_dexes)
}
fn insert(
&mut self,
dex: &SmolStr,
dex_index: usize,
meta: DexMeta,
tokens: &HashMap<usize, SmolStr>,
) -> Result<(), HyperliquidConnectError> {
let collateral = tokens.get(&meta.collateral_token).ok_or_else(|| {
HyperliquidConnectError::Metadata(format!(
"DEX {dex:?} settles in token {}, which spotMeta does not list",
meta.collateral_token
))
})?;
let first_asset = u32::try_from(dex_index)
.ok()
.and_then(|index| index.checked_mul(HIP3_ASSETS_PER_DEX))
.and_then(|offset| offset.checked_add(HIP3_ASSET_BASE))
.ok_or_else(|| {
HyperliquidConnectError::Metadata(format!(
"DEX {dex:?} is listed at index {dex_index}, past any asset id"
))
})?;
for (index, perp) in meta.universe.into_iter().enumerate() {
let asset = u32::try_from(index)
.ok()
.filter(|index| *index < HIP3_ASSETS_PER_DEX)
.map(|index| first_asset + index)
.ok_or_else(|| {
HyperliquidConnectError::Metadata(format!(
"DEX {dex:?} lists more than {HIP3_ASSETS_PER_DEX} perpetuals"
))
})?;
self.sz_decimals.insert(perp.name.clone(), perp.sz_decimals);
self.asset_ids.insert(perp.name, asset);
}
self.collateral.insert(dex.clone(), collateral.clone());
Ok(())
}
pub(super) fn asset_ids(&self) -> impl Iterator<Item = (String, u32)> + '_ {
self.asset_ids
.iter()
.map(|(coin, asset)| (coin.clone(), *asset))
}
pub(super) fn add_default_perps(&mut self, universe: &[hyperliquid_rust_sdk::AssetMeta]) {
self.sz_decimals.extend(
universe
.iter()
.map(|asset| (asset.name.clone(), asset.sz_decimals)),
);
}
pub(super) fn sz_decimals(&self, coin: &str) -> Option<u32> {
self.sz_decimals.get(coin).copied()
}
pub(super) fn hip3(&self) -> impl Iterator<Item = &str> {
self.collateral.keys().map(SmolStr::as_str)
}
pub(super) fn collateral(&self, dex: Option<&str>) -> Option<&str> {
match dex {
None => Some(DEFAULT_COLLATERAL),
Some(dex) => self.collateral.get(dex).map(SmolStr::as_str),
}
}
pub(super) fn collaterals(&self) -> BTreeSet<&str> {
std::iter::once(DEFAULT_COLLATERAL)
.chain(self.collateral.values().map(SmolStr::as_str))
.collect()
}
pub(super) fn perp(&self, coin: &str) -> Option<(InstrumentNameExchange, &str)> {
if CoinKind::of(coin) != CoinKind::Perp {
return None;
}
let collateral = self.collateral(dex_of(coin))?;
let instrument = InstrumentNameExchange::from(format_smolstr!("{coin}-{collateral}-PERP"));
Some((instrument, collateral))
}
pub(super) fn instrument(&self, coin: &str) -> Option<InstrumentNameExchange> {
self.perp(coin).map(|(instrument, _)| instrument)
}
fn unconfigured_dex<'a>(&self, coin: &'a str) -> Option<&'a str> {
let dex = dex_of(coin)?;
(CoinKind::of(coin) == CoinKind::Perp && !self.collateral.contains_key(dex)).then_some(dex)
}
pub(super) fn coin<'a>(
&self,
instrument: &'a InstrumentNameExchange,
) -> Result<&'a str, String> {
let name = instrument.as_ref();
let Some((coin, quote)) = name
.strip_suffix("-PERP")
.and_then(|rest| rest.rsplit_once('-'))
.filter(|(coin, _)| CoinKind::of(coin) == CoinKind::Perp)
else {
return Err(format!(
"{name} is not a Hyperliquid perpetual: expected {{coin}}-{{collateral}}-PERP, \
such as BTC-USDC-PERP"
));
};
let dex = dex_of(coin);
match self.collateral(dex) {
Some(collateral) if collateral == quote => Ok(coin),
Some(collateral) => Err(format!(
"{name} is quoted in {quote}, but {coin} settles in {collateral}: expected \
{coin}-{collateral}-PERP"
)),
None => Err(format!(
"{name} is on the HIP-3 DEX {:?}, which HyperliquidConfig::dexes does not name",
dex.unwrap_or_default()
)),
}
}
}
#[derive(Debug, Default)]
pub(super) struct UnconfiguredDexes(HashSet<SmolStr>);
impl UnconfiguredDexes {
pub(super) fn warn_once(&mut self, dexes: &PerpDexes, coin: &str) {
if let Some(dex) = dexes.unconfigured_dex(coin)
&& !self.0.contains(dex)
{
warn!(
%dex,
%coin,
"Hyperliquid perpetual is on a DEX this client is not configured with, so its \
collateral is unknown; leaving that DEX's coins out (logged once per stream)"
);
self.0.insert(SmolStr::from(dex));
}
}
}
pub(super) fn warn_unconfigured_dexes<'a>(
dexes: &PerpDexes,
coins: impl IntoIterator<Item = &'a str>,
) {
let unconfigured = coins
.into_iter()
.filter_map(|coin| dexes.unconfigured_dex(coin))
.collect::<BTreeSet<_>>();
if !unconfigured.is_empty() {
warn!(
?unconfigured,
"Hyperliquid perpetuals are on DEXs this client is not configured with, so their \
collateral is unknown; leaving them out"
);
}
}
pub(super) fn dex_of(coin: &str) -> Option<&str> {
coin.split_once(':').map(|(dex, _)| dex)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
pub(super) mod tests {
use super::super::common::info_tests::info_client_against;
use super::*;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_partial_json, method, path},
};
fn perp_dexs() -> serde_json::Value {
serde_json::json!([
null,
{"name": "xyz", "fullName": "XYZ", "deployer": "0x0"},
{"name": "flx", "fullName": "Felix Exchange", "deployer": "0x0"}
])
}
fn spot_meta() -> serde_json::Value {
serde_json::json!({
"universe": [],
"tokens": [
{"name": "USDC", "index": 0},
{"name": "USDH", "index": 360}
]
})
}
async fn mount(server: &MockServer, request: serde_json::Value, response: serde_json::Value) {
Mock::given(method("POST"))
.and(path("/info"))
.and(body_partial_json(request))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(server)
.await;
}
pub(in super::super) async fn dex_server() -> MockServer {
let server = MockServer::start().await;
mount(
&server,
serde_json::json!({"type": "perpDexs"}),
perp_dexs(),
)
.await;
mount(
&server,
serde_json::json!({"type": "spotMeta"}),
spot_meta(),
)
.await;
mount(
&server,
serde_json::json!({"type": "meta", "dex": "xyz"}),
serde_json::json!({
"universe": [{"name": "xyz:TSLA", "szDecimals": 2}, {"name": "xyz:NVDA", "szDecimals": 2}],
"collateralToken": 0
}),
)
.await;
mount(
&server,
serde_json::json!({"type": "meta", "dex": "flx"}),
serde_json::json!({
"universe": [{"name": "flx:TSLA", "szDecimals": 2}],
"collateralToken": 360
}),
)
.await;
server
}
pub(in super::super) async fn xyz_and_flx() -> PerpDexes {
let server = dex_server().await;
let info_client = info_client_against(server.uri()).await;
PerpDexes::fetch(&info_client, &["xyz".into(), "flx".into()])
.await
.unwrap()
}
#[tokio::test]
async fn hip3_perpetuals_get_asset_ids_from_their_dex_index() {
let dexes = xyz_and_flx().await;
let mut ids = dexes.asset_ids().collect::<Vec<_>>();
ids.sort();
assert_eq!(
ids,
[
("flx:TSLA".to_owned(), 120_000),
("xyz:NVDA".to_owned(), 110_001),
("xyz:TSLA".to_owned(), 110_000),
]
);
}
#[tokio::test]
async fn perpetuals_are_named_after_their_dex_collateral() {
let dexes = xyz_and_flx().await;
let name = |coin| dexes.instrument(coin).map(|name| name.to_string());
assert_eq!(name("BTC").as_deref(), Some("BTC-USDC-PERP"));
assert_eq!(name("xyz:TSLA").as_deref(), Some("xyz:TSLA-USDC-PERP"));
assert_eq!(name("flx:TSLA").as_deref(), Some("flx:TSLA-USDH-PERP"));
assert_eq!(name("km:TSLA"), None);
assert_eq!(name("@107"), None);
assert_eq!(name("#12"), None);
}
#[tokio::test]
async fn an_instrument_names_its_coin_only_with_its_dex_collateral() {
let dexes = xyz_and_flx().await;
let coin = |name: &str| {
dexes
.coin(&InstrumentNameExchange::from(name))
.map(str::to_owned)
};
assert_eq!(coin("BTC-USDC-PERP").unwrap(), "BTC");
assert_eq!(coin("kPEPE-USDC-PERP").unwrap(), "kPEPE");
assert_eq!(coin("flx:TSLA-USDH-PERP").unwrap(), "flx:TSLA");
assert_eq!(coin("xyz:TSLA-USDC-PERP").unwrap(), "xyz:TSLA");
assert!(
coin("BTC-USD-PERP")
.unwrap_err()
.contains("settles in USDC")
);
assert!(
coin("flx:TSLA-USDC-PERP")
.unwrap_err()
.contains("settles in USDH")
);
assert!(coin("km:TSLA-USDH-PERP").unwrap_err().contains("\"km\""));
assert!(coin("BTC").is_err());
assert!(coin("HYPE-USDC-SPOT").is_err());
assert!(coin("@107-USDC-PERP").is_err());
}
#[tokio::test]
async fn only_perpetuals_on_unconfigured_dexes_are_logged_once_each() {
let dexes = xyz_and_flx().await;
let mut logged = UnconfiguredDexes::default();
for coin in ["km:TSLA", "km:NVDA", "xyz:TSLA", "BTC", "@107", "hyna:BTC"] {
logged.warn_once(&dexes, coin);
}
assert_eq!(logged.0, HashSet::from(["km".into(), "hyna".into()]));
}
#[tokio::test]
async fn a_perpetual_comes_with_its_collateral() {
let dexes = xyz_and_flx().await;
let (instrument, collateral) = dexes.perp("flx:TSLA").unwrap();
assert_eq!(
(instrument.as_ref(), collateral),
("flx:TSLA-USDH-PERP", "USDH")
);
}
#[tokio::test]
async fn the_collaterals_are_each_dex_token_once() {
let dexes = xyz_and_flx().await;
assert_eq!(
dexes.collaterals().into_iter().collect::<Vec<_>>(),
["USDC", "USDH"]
);
}
#[tokio::test]
async fn no_dex_configured_reads_nothing_and_names_default_perpetuals() {
let server = MockServer::start().await;
let info_client = info_client_against(server.uri()).await;
let dexes = PerpDexes::fetch(&info_client, &[]).await.unwrap();
assert_eq!(dexes.asset_ids().count(), 0);
assert_eq!(dexes.instrument("ETH").unwrap().as_ref(), "ETH-USDC-PERP");
assert_eq!(dexes.instrument("xyz:TSLA"), None);
}
#[tokio::test]
async fn a_dex_hyperliquid_does_not_list_fails_the_read() {
let server = dex_server().await;
let info_client = info_client_against(server.uri()).await;
let error = PerpDexes::fetch(&info_client, &["xyz".into(), "XYZ".into()])
.await
.unwrap_err();
assert_eq!(error, HyperliquidConnectError::UnknownDex("XYZ".into()));
}
#[tokio::test]
async fn a_collateral_spot_meta_does_not_list_fails_the_read() {
let server = MockServer::start().await;
mount(
&server,
serde_json::json!({"type": "perpDexs"}),
perp_dexs(),
)
.await;
mount(
&server,
serde_json::json!({"type": "spotMeta"}),
spot_meta(),
)
.await;
mount(
&server,
serde_json::json!({"type": "meta", "dex": "xyz"}),
serde_json::json!({"universe": [], "collateralToken": 999}),
)
.await;
let info_client = info_client_against(server.uri()).await;
let error = PerpDexes::fetch(&info_client, &["xyz".into()])
.await
.unwrap_err();
assert!(
matches!(error, HyperliquidConnectError::Metadata(reason) if reason.contains("999"))
);
}
}