use std::{
collections::{HashMap, HashSet},
sync::{Arc, LazyLock, RwLock},
time::Duration,
};
use num_bigint::BigUint;
use reqwest::Client;
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use tycho_simulation::tycho_common::models::{token::Token, Address};
use super::{
provider::{ExternalPrice, PriceProvider, PriceProviderError},
utils::{check_staleness, expected_out_from_price},
};
use crate::feed::market_data::SharedMarketDataRef;
const API_URL: &str = "https://api.hyperliquid.xyz/info";
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(3);
static USD_STABLECOINS: LazyLock<HashSet<String>> = LazyLock::new(|| {
serde_json::from_str(include_str!("stable_usd.json")).expect("stable_usd.json is valid")
});
type PriceCache = Arc<RwLock<HashMap<String, OraclePrice>>>;
type TokenCache = Arc<RwLock<HashMap<Address, Token>>>;
fn normalize_symbol(symbol: &str) -> (String, f64) {
match symbol.to_uppercase().as_str() {
"WETH" => ("ETH".to_string(), 1.0),
"WBTC" => ("BTC".to_string(), 1.0),
"WBNB" => ("BNB".to_string(), 1.0),
"WMATIC" => ("MATIC".to_string(), 1.0),
"WAVAX" => ("AVAX".to_string(), 1.0),
"WFTM" => ("FTM".to_string(), 1.0),
"PEPE" => ("kPEPE".to_string(), 0.001),
"SHIB" => ("kSHIB".to_string(), 0.001),
"BONK" => ("kBONK".to_string(), 0.001),
"FLOKI" => ("kFLOKI".to_string(), 0.001),
"LUNC" => ("kLUNC".to_string(), 0.001),
"NEIRO" => ("kNEIRO".to_string(), 0.001),
"DOGS" => ("kDOGS".to_string(), 0.001),
other => (other.to_string(), 1.0),
}
}
#[derive(Debug, Clone)]
struct OraclePrice {
usd_price: f64,
timestamp_ms: u64,
}
pub struct HyperliquidProvider {
price_cache: PriceCache,
token_cache: TokenCache,
poll_interval: Duration,
api_url: String,
}
impl HyperliquidProvider {
pub fn new(poll_interval: Duration) -> Self {
Self {
price_cache: Arc::new(RwLock::new(HashMap::new())),
token_cache: Arc::new(RwLock::new(HashMap::new())),
poll_interval,
api_url: API_URL.to_string(),
}
}
fn resolve_token(&self, address: &Address) -> Result<(String, u32, f64), PriceProviderError> {
let cache = self
.token_cache
.read()
.map_err(|e| PriceProviderError::Unavailable(format!("token cache poisoned: {e}")))?;
let info = cache
.get(address)
.ok_or_else(|| PriceProviderError::TokenNotFound { address: address.to_string() })?;
let (symbol, price_scale) = normalize_symbol(&info.symbol);
Ok((symbol, info.decimals, price_scale))
}
}
impl Default for HyperliquidProvider {
fn default() -> Self {
Self::new(DEFAULT_POLL_INTERVAL)
}
}
impl PriceProvider for HyperliquidProvider {
fn start(&mut self, market_data: SharedMarketDataRef) -> JoinHandle<()> {
let worker = HyperliquidWorker {
price_cache: Arc::clone(&self.price_cache),
token_cache: Arc::clone(&self.token_cache),
market_data,
client: Client::new(),
poll_interval: self.poll_interval,
api_url: self.api_url.clone(),
};
tokio::spawn(async move { worker.run().await })
}
fn get_expected_out(
&self,
token_in: &Address,
token_out: &Address,
amount_in: &BigUint,
) -> Result<ExternalPrice, PriceProviderError> {
let (sym_in, dec_in, scale_in) = self.resolve_token(token_in)?;
let (sym_out, dec_out, scale_out) = self.resolve_token(token_out)?;
let cache = self
.price_cache
.read()
.map_err(|e| PriceProviderError::Unavailable(format!("price cache poisoned: {e}")))?;
let price_in = cache
.get(&sym_in)
.ok_or(PriceProviderError::PriceNotFound {
token_in: sym_in.clone(),
token_out: sym_out.clone(),
})?;
let price_out = cache
.get(&sym_out)
.ok_or(PriceProviderError::PriceNotFound { token_in: sym_in, token_out: sym_out })?;
let oldest_ts = price_in
.timestamp_ms
.min(price_out.timestamp_ms);
check_staleness(oldest_ts)?;
if price_out.usd_price == 0.0 {
return Err(PriceProviderError::Unavailable("zero oracle price".into()));
}
let usd_in = price_in.usd_price * scale_in;
let usd_out = price_out.usd_price * scale_out;
let price = usd_in / usd_out;
let expected_out = expected_out_from_price(amount_in, price, dec_in, dec_out);
Ok(ExternalPrice::new(expected_out, "hyperliquid".to_string(), oldest_ts))
}
}
struct HyperliquidWorker {
price_cache: PriceCache,
token_cache: TokenCache,
market_data: SharedMarketDataRef,
client: Client,
poll_interval: Duration,
api_url: String,
}
impl HyperliquidWorker {
async fn run(&self) {
loop {
self.refresh_token_cache().await;
match self.poll_prices().await {
Ok(count) => debug!(count, "updated Hyperliquid oracle prices"),
Err(e) => warn!(error = %e, "failed to poll Hyperliquid oracle"),
}
tokio::time::sleep(self.poll_interval).await;
}
}
async fn refresh_token_cache(&self) {
let current_len = self
.token_cache
.read()
.map(|c| c.len())
.unwrap_or(0);
let new_cache: HashMap<Address, Token> = {
let data = self.market_data.read().await;
let registry = data.token_registry_ref();
if registry.len() == current_len {
return;
}
registry
.iter()
.map(|(address, token)| (address.clone(), token.clone()))
.collect()
};
let mut cache = match self.token_cache.write() {
Ok(c) => c,
Err(e) => {
warn!(error = %e, "token cache lock poisoned");
return;
}
};
*cache = new_cache;
}
async fn poll_prices(&self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
let resp = self
.client
.post(&self.api_url)
.json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
.send()
.await?;
let (meta, asset_ctxs): (Meta, Vec<AssetCtx>) = resp.json().await?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let mut new_cache: HashMap<String, OraclePrice> = HashMap::new();
let mut count = 0;
for (asset, ctx) in meta
.universe
.iter()
.zip(asset_ctxs.iter())
{
if let Ok(usd_price) = ctx.oracle_px.parse::<f64>() {
if usd_price > 0.0 {
new_cache.insert(
asset.name.clone(),
OraclePrice { usd_price, timestamp_ms: now_ms },
);
count += 1;
}
}
}
for stable in USD_STABLECOINS.iter() {
new_cache
.entry(stable.clone())
.or_insert(OraclePrice { usd_price: 1.0, timestamp_ms: now_ms });
}
let mut cache = self
.price_cache
.write()
.map_err(|e| format!("price cache poisoned: {e}"))?;
*cache = new_cache;
Ok(count)
}
}
#[derive(serde::Deserialize)]
struct Meta {
universe: Vec<AssetMeta>,
}
#[derive(serde::Deserialize)]
struct AssetMeta {
name: String,
}
#[derive(serde::Deserialize)]
struct AssetCtx {
#[serde(rename = "oraclePx")]
oracle_px: String,
}
#[cfg(test)]
mod tests {
use tokio::sync::RwLock;
use tycho_simulation::{evm::tycho_models::Chain, tycho_common::models::token::Token};
use super::*;
use crate::feed::market_data::SharedMarketData;
fn make_token(address: Address, symbol: &str, decimals: u32) -> Token {
Token {
address,
symbol: symbol.to_string(),
decimals,
tax: Default::default(),
gas: vec![],
chain: Chain::Ethereum,
quality: 100,
}
}
fn weth_address() -> Address {
"C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
.parse()
.expect("valid address")
}
fn usdc_address() -> Address {
"A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
.parse()
.expect("valid address")
}
fn seeded_provider() -> HyperliquidProvider {
let provider = HyperliquidProvider::default();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
{
let mut cache = provider
.price_cache
.write()
.expect("lock");
cache
.insert("ETH".to_string(), OraclePrice { usd_price: 2000.0, timestamp_ms: now_ms });
cache.insert("USDC".to_string(), OraclePrice { usd_price: 1.0, timestamp_ms: now_ms });
cache.insert("LINK".to_string(), OraclePrice { usd_price: 15.0, timestamp_ms: now_ms });
cache
.insert("AAVE".to_string(), OraclePrice { usd_price: 200.0, timestamp_ms: now_ms });
}
{
let mut cache = provider
.token_cache
.write()
.expect("lock");
let weth = make_token(weth_address(), "WETH", 18);
cache.insert(weth.address.clone(), weth);
let usdc = make_token(usdc_address(), "USDC", 6);
cache.insert(usdc.address.clone(), usdc);
let link_addr: Address = "514910771AF9Ca656af840dff83E8264EcF986CA"
.parse()
.expect("valid address");
let link = make_token(link_addr, "LINK", 18);
cache.insert(link.address.clone(), link);
let aave_addr: Address = "7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9"
.parse()
.expect("valid address");
let aave = make_token(aave_addr, "AAVE", 18);
cache.insert(aave.address.clone(), aave);
}
provider
}
#[test]
fn test_price_from_usd_oracle() {
let provider = seeded_provider();
let one_eth = BigUint::from(10u64).pow(18);
let result = provider
.get_expected_out(&weth_address(), &usdc_address(), &one_eth)
.expect("should get price");
assert_eq!(*result.expected_amount_out(), BigUint::from(2_000_000_000u64));
assert_eq!(result.source(), "hyperliquid");
}
#[test]
fn test_cross_pair_via_usd() {
let provider = seeded_provider();
let link_addr: Address = "514910771AF9Ca656af840dff83E8264EcF986CA"
.parse()
.expect("valid address");
let aave_addr: Address = "7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9"
.parse()
.expect("valid address");
let ten_link = BigUint::from(10u64) * BigUint::from(10u64).pow(18);
let result = provider
.get_expected_out(&link_addr, &aave_addr, &ten_link)
.expect("should get price");
let expected = BigUint::from(75u64) * BigUint::from(10u64).pow(16); let diff = if *result.expected_amount_out() > expected {
result.expected_amount_out() - &expected
} else {
&expected - result.expected_amount_out()
};
let tolerance = &expected / BigUint::from(1000u64); assert!(diff < tolerance, "result={}, expected ~{expected}", result.expected_amount_out());
}
#[test]
fn test_unknown_token_returns_error() {
let provider = seeded_provider();
let unknown: Address = "0000000000000000000000000000000000000001"
.parse()
.expect("valid");
let one = BigUint::from(10u64).pow(18);
let result = provider.get_expected_out(&unknown, &usdc_address(), &one);
assert!(result.is_err());
assert!(matches!(result, Err(PriceProviderError::TokenNotFound { .. })));
}
#[test]
fn test_stale_price_returns_error() {
let provider = HyperliquidProvider::default();
let stale_ts = 1_000u64;
{
let mut cache = provider
.price_cache
.write()
.expect("lock");
cache.insert(
"ETH".to_string(),
OraclePrice { usd_price: 2000.0, timestamp_ms: stale_ts },
);
cache
.insert("USDC".to_string(), OraclePrice { usd_price: 1.0, timestamp_ms: stale_ts });
}
{
let mut cache = provider
.token_cache
.write()
.expect("lock");
let weth = make_token(weth_address(), "WETH", 18);
cache.insert(weth.address.clone(), weth);
let usdc = make_token(usdc_address(), "USDC", 6);
cache.insert(usdc.address.clone(), usdc);
}
let one_eth = BigUint::from(10u64).pow(18);
let result = provider.get_expected_out(&weth_address(), &usdc_address(), &one_eth);
assert!(result.is_err());
assert!(matches!(result, Err(PriceProviderError::StaleData { .. })));
}
#[test]
fn test_parse_meta_and_asset_ctxs() {
let json = r#"[
{"universe": [{"name": "BTC", "szDecimals": 5}, {"name": "ETH", "szDecimals": 4}]},
[{"oraclePx": "66820.0", "markPx": "66787.0"}, {"oraclePx": "1989.0", "markPx": "1988.0"}]
]"#;
let (meta, ctxs): (Meta, Vec<AssetCtx>) = serde_json::from_str(json).expect("should parse");
assert_eq!(meta.universe.len(), 2);
assert_eq!(meta.universe[0].name, "BTC");
assert_eq!(meta.universe[1].name, "ETH");
assert_eq!(ctxs[0].oracle_px, "66820.0");
assert_eq!(ctxs[1].oracle_px, "1989.0");
}
#[tokio::test]
#[ignore] async fn test_hyperliquid_live_pepe_usdc() {
let pepe_addr: Address = "6982508145454Ce325dDbE47a25d4ec3d2311933"
.parse()
.expect("valid address");
let pepe = make_token(pepe_addr.clone(), "PEPE", 18);
let usdc = make_token(usdc_address(), "USDC", 6);
let mut market_data = SharedMarketData::new();
market_data.upsert_tokens([pepe, usdc]);
let market_data = Arc::new(RwLock::new(market_data));
let mut provider = HyperliquidProvider::default();
let _handle = provider.start(market_data);
tokio::time::sleep(Duration::from_secs(5)).await;
let one_billion_pepe = BigUint::from(10u64).pow(27);
let price = provider
.get_expected_out(&pepe_addr, &usdc_address(), &one_billion_pepe)
.expect("should get a price from Hyperliquid for PEPE");
let min = BigUint::from(1_000_000_000u64); let max = BigUint::from(100_000_000_000u64); let amount = price.expected_amount_out();
assert!(
*amount >= min && *amount <= max,
"expected 1B PEPE worth in [{min}, {max}] USDC, got {amount}"
);
}
#[tokio::test]
#[ignore] async fn test_hyperliquid_live_weth_usdc() {
let weth = make_token(weth_address(), "WETH", 18);
let usdc = make_token(usdc_address(), "USDC", 6);
let mut market_data = SharedMarketData::new();
market_data.upsert_tokens([weth, usdc]);
let market_data = Arc::new(RwLock::new(market_data));
let mut provider = HyperliquidProvider::default();
let _handle = provider.start(market_data);
tokio::time::sleep(Duration::from_secs(5)).await;
let one_eth = BigUint::from(10u64).pow(18);
let price = provider
.get_expected_out(&weth_address(), &usdc_address(), &one_eth)
.expect("should get a price from Hyperliquid");
let min = BigUint::from(1_000_000_000u64); let max = BigUint::from(10_000_000_000u64); let amount = price.expected_amount_out();
assert!(
*amount >= min && *amount <= max,
"expected amount_out in [{min}, {max}], got {amount}"
);
}
}