mod native;
mod parse;
mod rest;
mod sign;
mod stream;
use std::fmt;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::task::{Context, Poll};
use futures_core::Stream;
use futures_util::StreamExt;
use rust_decimal::Decimal;
use tokio::sync::{Mutex, OnceCell};
use crate::adapter::{Adapter, BoxFuture};
use crate::error::{Error, Result};
use crate::feature::Feature;
use crate::request::{
CandleRequest, HistoryRequest, MarginRequest, OrderRequest, TransferHistoryRequest,
};
use crate::stream::{AccountStream, MarketStream, TypedStream};
use crate::transport::{HttpTransport, WsCommand, WsConnect, ws};
use crate::types::{
AccountEvent, Balance, Candle, Cursor, Deposit, Exchange, FundingPayment, FundingRate,
MarginSummary, Market, MarketEvent, MarketInfo, MarketKind, Order, OrderBook, Page, Position,
StreamConfig, Subscription, Ticker, Timestamp, Trade, Withdrawal,
};
use parse::Universe;
#[allow(unused_imports)]
pub use native::{
HyperliquidAccountEvent, HyperliquidAssetContext, HyperliquidAssetContextEvent,
HyperliquidBookLevel, HyperliquidCandleEvent, HyperliquidCandleSnapshot,
HyperliquidDailyVolume, HyperliquidEvmContract, HyperliquidFundingHistoryEntry,
HyperliquidL2Book, HyperliquidLedgerEntry, HyperliquidLedgerKind, HyperliquidMarketEvent,
HyperliquidOpenOrder, HyperliquidOrderBookEvent, HyperliquidOrderDetail, HyperliquidOrderInfo,
HyperliquidOrderReference, HyperliquidOrderStatusResponse, HyperliquidOrderUpdate,
HyperliquidPortfolioPeriod, HyperliquidPortfolioPoint, HyperliquidRecentTrade,
HyperliquidReferral, HyperliquidReferrer, HyperliquidSpotAssetContext, HyperliquidSpotBalance,
HyperliquidSpotClearinghouseState, HyperliquidSpotMeta, HyperliquidSpotMetaAndAssetContexts,
HyperliquidSpotPair, HyperliquidSpotStateBalance, HyperliquidSpotStateEvent,
HyperliquidSpotToken, HyperliquidSubAccount, HyperliquidTradeEvent, HyperliquidUserFees,
HyperliquidUserFill, HyperliquidUserFunding, HyperliquidUserRateLimit, HyperliquidUserRole,
HyperliquidVaultEquity,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HyperliquidMidPrice {
pub market: Market,
pub price: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HyperliquidAllMids {
pub mids: Vec<HyperliquidMidPrice>,
pub raw_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HyperliquidProviderResponse {
pub raw_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HyperliquidOrderActionResponse {
pub common: Order,
pub raw_json: String,
}
pub(crate) const MAINNET_REST_BASE_URL: &str = "https://api.hyperliquid.xyz";
pub(crate) const MAINNET_WEBSOCKET_URL: &str = "wss://api.hyperliquid.xyz/ws";
pub(crate) const TESTNET_REST_BASE_URL: &str = "https://api.hyperliquid-testnet.xyz";
pub(crate) const TESTNET_WEBSOCKET_URL: &str = "wss://api.hyperliquid-testnet.xyz/ws";
static LAST_NONCE: AtomicU64 = AtomicU64::new(0);
static SIGNED_ACTION_LANE: Mutex<()> = Mutex::const_new(());
#[derive(Debug, Clone)]
pub struct HyperliquidAdapter {
network: HyperliquidNetwork,
query_address: Option<String>,
signer: Option<HyperliquidSigner>,
connection: OnceCell<Connection>,
}
pub struct HyperliquidMarketStream {
inner: TypedStream<HyperliquidMarketEvent>,
}
impl HyperliquidMarketStream {
fn new_with_close<F, Fut>(
inner: impl Stream<Item = Result<HyperliquidMarketEvent>> + Send + 'static,
close: F,
) -> Self
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<()>> + Send + 'static,
{
Self {
inner: TypedStream::new_with_close(inner, close),
}
}
pub async fn close(&mut self) -> Result<()> {
self.inner.close().await
}
}
impl Stream for HyperliquidMarketStream {
type Item = Result<HyperliquidMarketEvent>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.inner).poll_next(cx)
}
}
impl fmt::Debug for HyperliquidMarketStream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HyperliquidMarketStream")
.finish_non_exhaustive()
}
}
pub struct HyperliquidAccountStream {
inner: TypedStream<HyperliquidAccountEvent>,
}
impl HyperliquidAccountStream {
fn new_with_close<F, Fut>(
inner: impl Stream<Item = Result<HyperliquidAccountEvent>> + Send + 'static,
close: F,
) -> Self
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<()>> + Send + 'static,
{
Self {
inner: TypedStream::new_with_close(inner, close),
}
}
pub async fn close(&mut self) -> Result<()> {
self.inner.close().await
}
}
impl Stream for HyperliquidAccountStream {
type Item = Result<HyperliquidAccountEvent>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.inner).poll_next(cx)
}
}
impl fmt::Debug for HyperliquidAccountStream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HyperliquidAccountStream")
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
struct Connection {
http: HttpTransport,
universe: Universe,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub(crate) enum HyperliquidNetwork {
#[default]
Mainnet,
Testnet,
}
#[derive(Clone)]
pub(crate) struct HyperliquidSigner {
pub(crate) private_key: String,
}
impl std::fmt::Debug for HyperliquidSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HyperliquidSigner")
.field("private_key", &"<redacted>")
.finish()
}
}
impl HyperliquidAdapter {
pub fn new() -> Self {
Self::on(HyperliquidNetwork::Mainnet)
}
pub fn testnet() -> Self {
Self::on(HyperliquidNetwork::Testnet)
}
fn on(network: HyperliquidNetwork) -> Self {
Self {
network,
query_address: None,
signer: None,
connection: OnceCell::new(),
}
}
#[must_use]
pub fn with_query_address(mut self, address: impl Into<String>) -> Self {
self.query_address = Some(address.into());
self
}
#[must_use]
pub fn with_signer(mut self, private_key: impl Into<String>) -> Self {
self.signer = Some(HyperliquidSigner {
private_key: private_key.into(),
});
self
}
#[must_use]
pub fn with_wallet(self, address: impl Into<String>, private_key: impl Into<String>) -> Self {
self.with_query_address(address).with_signer(private_key)
}
pub fn is_testnet(&self) -> bool {
self.network == HyperliquidNetwork::Testnet
}
pub(crate) fn has_query_address(&self) -> bool {
self.query_address.is_some()
}
pub(crate) fn has_signer(&self) -> bool {
self.signer.is_some()
}
fn next_nonce(&self, now: Timestamp) -> Result<u64> {
let now = u64::try_from(now.as_millis())
.map_err(|_| Error::adapter("hyperliquid nonce clock predates the Unix epoch"))?;
let previous = LAST_NONCE
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |last| {
last.checked_add(1).map(|next| now.max(next))
})
.map_err(|_| Error::adapter("hyperliquid nonce counter is exhausted"))?;
Ok(now.max(previous + 1))
}
pub(crate) fn rest_base_url(&self) -> &'static str {
match self.network {
HyperliquidNetwork::Mainnet => MAINNET_REST_BASE_URL,
HyperliquidNetwork::Testnet => TESTNET_REST_BASE_URL,
}
}
pub(crate) fn websocket_url(&self) -> &'static str {
match self.network {
HyperliquidNetwork::Mainnet => MAINNET_WEBSOCKET_URL,
HyperliquidNetwork::Testnet => TESTNET_WEBSOCKET_URL,
}
}
pub async fn non_funding_ledger(
&self,
from: Option<Timestamp>,
to: Option<Timestamp>,
cursor: Option<&Cursor>,
limit: Option<u32>,
) -> Result<Page<HyperliquidLedgerEntry>> {
rest::validate_page_limit(limit)?;
let user = self.query_address()?;
let connection = self.connect().await?;
rest::ledger(&connection.http, &user, from, to, cursor, limit).await
}
pub async fn user_rate_limit(&self) -> Result<HyperliquidUserRateLimit> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::user_rate_limit(&connection.http, &user).await
}
pub async fn user_role(&self) -> Result<HyperliquidUserRole> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::user_role(&connection.http, &user).await
}
pub async fn referral(&self) -> Result<HyperliquidReferral> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::referral(&connection.http, &user).await
}
pub async fn user_fees(&self) -> Result<HyperliquidUserFees> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::user_fees(&connection.http, &user).await
}
pub async fn portfolio(&self) -> Result<Vec<HyperliquidPortfolioPeriod>> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::portfolio(&connection.http, &user).await
}
pub async fn sub_accounts(&self) -> Result<Vec<HyperliquidSubAccount>> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::sub_accounts(&connection.http, &user).await
}
pub async fn user_vault_equities(&self) -> Result<Vec<HyperliquidVaultEquity>> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::user_vault_equities(&connection.http, &user).await
}
pub async fn user_fills(&self, aggregate_by_time: bool) -> Result<Vec<HyperliquidUserFill>> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::user_fills(&connection.http, &user, aggregate_by_time).await
}
pub async fn user_fills_by_time(
&self,
from: Timestamp,
to: Option<Timestamp>,
aggregate_by_time: bool,
) -> Result<Vec<HyperliquidUserFill>> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::user_fills_by_time(&connection.http, &user, from, to, aggregate_by_time).await
}
pub async fn basic_open_orders(&self) -> Result<Vec<HyperliquidOpenOrder>> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::basic_open_orders(&connection.http, &user).await
}
pub async fn order_status(
&self,
reference: HyperliquidOrderReference,
) -> Result<HyperliquidOrderStatusResponse> {
let user = self.query_address()?;
rest::validate_order_reference(&reference)?;
let connection = self.connect().await?;
rest::order_status(&connection.http, &user, &reference).await
}
pub async fn historical_orders(&self) -> Result<Vec<HyperliquidOrderInfo>> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::historical_orders(&connection.http, &user).await
}
pub async fn asset_context(&self, market: &Market) -> Result<HyperliquidAssetContext> {
let connection = self.connect().await?;
let raw = rest::context(&connection.http, &connection.universe, market).await?;
let asset = connection.universe.asset(market)?;
native::asset_context(&raw, asset)
}
pub async fn candle_snapshot(
&self,
market: &Market,
interval: &str,
from: Timestamp,
to: Option<Timestamp>,
) -> Result<Vec<HyperliquidCandleSnapshot>> {
if to.is_some_and(|to| to < from) {
return Err(Error::invalid_request(
"to",
"must not be earlier than `from`",
));
}
let connection = self.connect().await?;
let native = connection.universe.native_symbol(market)?;
rest::candle_snapshot(
&connection.http,
&connection.universe,
native,
interval,
from.as_millis(),
to.map(Timestamp::as_millis),
)
.await
}
pub async fn l2_book(&self, market: &Market) -> Result<HyperliquidL2Book> {
let connection = self.connect().await?;
rest::l2_book(&connection.http, &connection.universe, market).await
}
pub async fn recent_trades(&self, market: &Market) -> Result<Vec<HyperliquidRecentTrade>> {
let connection = self.connect().await?;
rest::recent_trades(&connection.http, &connection.universe, market).await
}
pub async fn funding_history(
&self,
market: &Market,
from: Timestamp,
to: Option<Timestamp>,
) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
if to.is_some_and(|to| to < from) {
return Err(Error::invalid_request(
"to",
"must not be earlier than `from`",
));
}
let connection = self.connect().await?;
rest::funding_history(&connection.http, &connection.universe, market, from, to).await
}
pub async fn user_funding(
&self,
from: Timestamp,
to: Option<Timestamp>,
) -> Result<Vec<HyperliquidUserFunding>> {
if to.is_some_and(|to| to < from) {
return Err(Error::invalid_request(
"to",
"must not be earlier than `from`",
));
}
let user = self.query_address()?;
let connection = self.connect().await?;
rest::user_funding(&connection.http, &connection.universe, &user, from, to).await
}
pub async fn spot_clearinghouse_state(&self) -> Result<HyperliquidSpotClearinghouseState> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::spot_clearinghouse_state(&connection.http, &user).await
}
pub async fn spot_meta(&self) -> Result<HyperliquidSpotMeta> {
let connection = self.connect().await?;
rest::spot_meta(&connection.http).await
}
pub async fn spot_meta_and_asset_contexts(
&self,
) -> Result<HyperliquidSpotMetaAndAssetContexts> {
let connection = self.connect().await?;
rest::spot_meta_and_asset_contexts(&connection.http).await
}
pub async fn all_mids(&self) -> Result<Vec<HyperliquidMidPrice>> {
self.all_mids_detail().await.map(|response| response.mids)
}
pub async fn all_mids_detail(&self) -> Result<HyperliquidAllMids> {
let connection = self.connect().await?;
rest::all_mids_detail(&connection.http, &connection.universe).await
}
pub async fn perpetual_meta(&self) -> Result<HyperliquidProviderResponse> {
let connection = self.connect().await?;
rest::perpetual_meta(&connection.http).await
}
pub async fn perpetual_meta_and_asset_contexts(&self) -> Result<HyperliquidProviderResponse> {
let connection = self.connect().await?;
rest::perpetual_meta_and_asset_contexts(&connection.http).await
}
pub async fn clearinghouse_state_detail(&self) -> Result<HyperliquidProviderResponse> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::clearinghouse_state(&connection.http, &user).await
}
pub async fn frontend_open_orders_detail(&self) -> Result<HyperliquidProviderResponse> {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::frontend_open_orders(&connection.http, &user).await
}
pub async fn place_order_detail(
&self,
request: &OrderRequest,
) -> Result<HyperliquidOrderActionResponse> {
let private_key = self.signing_key()?;
let connection = self.connect().await?;
let _lane = SIGNED_ACTION_LANE.lock().await;
let nonce = self.next_nonce(Timestamp::now())?;
rest::place_order_detail(
&connection.http,
&connection.universe,
private_key,
self.network,
request,
nonce,
)
.await
}
pub async fn cancel_order_detail(
&self,
market: &Market,
order_id: &str,
) -> Result<HyperliquidProviderResponse> {
let private_key = self.signing_key()?;
let connection = self.connect().await?;
let _lane = SIGNED_ACTION_LANE.lock().await;
let nonce = self.next_nonce(Timestamp::now())?;
rest::cancel_order_detail(
&connection.http,
&connection.universe,
private_key,
self.network,
market,
order_id,
nonce,
)
.await
}
pub async fn subscribe_detailed(
&self,
subscription: &Subscription,
) -> Result<HyperliquidMarketStream> {
self.subscribe_detailed_with(subscription, &crate::client::default_stream_config())
.await
}
pub async fn subscribe_detailed_with(
&self,
subscription: &Subscription,
config: &StreamConfig,
) -> Result<HyperliquidMarketStream> {
let connection = self.connect().await?;
let frames = stream::subscribe_frames(subscription, &connection.universe)?;
let session = ws::connect(
WsConnect {
url: self.websocket_url().to_string(),
headers: None,
subscribe: WsConnect::fixed(frames),
heartbeat: Some(stream::HEARTBEAT),
},
config,
)
.await?;
let close = session.close_handle();
let universe = connection.universe.clone();
let mut decoder = stream::DetailedDecoder::default();
Ok(HyperliquidMarketStream::new_with_close(
session.flat_map(move |command| {
futures_util::stream::iter(detailed_market_events(command, &universe, &mut decoder))
}),
move || async move { close.close().await },
))
}
pub async fn subscribe_detailed_account(&self) -> Result<HyperliquidAccountStream> {
self.subscribe_detailed_account_with(&crate::client::default_stream_config())
.await
}
pub async fn subscribe_detailed_account_with(
&self,
config: &StreamConfig,
) -> Result<HyperliquidAccountStream> {
let user = self.query_address()?;
let connection = self.connect().await?;
let session = ws::connect(
WsConnect {
url: self.websocket_url().to_string(),
headers: None,
subscribe: WsConnect::fixed(stream::account_subscribe_frames(&user)),
heartbeat: Some(stream::HEARTBEAT),
},
config,
)
.await?;
let close = session.close_handle();
let universe = connection.universe.clone();
Ok(HyperliquidAccountStream::new_with_close(
session.flat_map(move |command| {
futures_util::stream::iter(detailed_account_events(command, &universe))
}),
move || async move { close.close().await },
))
}
async fn connect(&self) -> Result<&Connection> {
self.connection
.get_or_try_init(|| async {
let http = HttpTransport::new(self.rest_base_url())?;
let universe = rest::universe(&http).await?;
Ok(Connection { http, universe })
})
.await
}
fn query_address(&self) -> Result<String> {
let address = self
.query_address
.as_deref()
.ok_or_else(sign::missing_query_address)?;
sign::check_address(address)
}
fn signing_key(&self) -> Result<&str> {
let private_key = self
.signer
.as_ref()
.map(|signer| signer.private_key.as_str())
.ok_or_else(sign::missing_signer)?;
sign::signing_key(private_key)?;
Ok(private_key)
}
}
impl Default for HyperliquidAdapter {
fn default() -> Self {
Self::new()
}
}
impl Adapter for HyperliquidAdapter {
fn exchange(&self) -> Exchange {
Exchange::Hyperliquid
}
fn supports(&self, feature: Feature) -> bool {
match feature {
Feature::AssetNetworks
| Feature::DepositAddresses
| Feature::DepositLookup
| Feature::TravelRule
| Feature::WithdrawalQuotes
| Feature::Withdrawals
| Feature::WithdrawalLookup
| Feature::WithdrawalCancellation
| Feature::OrderHistory => false,
Feature::Balances
| Feature::DepositHistory
| Feature::WithdrawalHistory
| Feature::OpenOrders
| Feature::AccountStream
| Feature::Positions
| Feature::Margin
| Feature::FundingPayments => self.has_query_address(),
Feature::Trading | Feature::MarginConfig | Feature::ReduceOnlyOrders => {
self.has_signer()
}
_ => true,
}
}
fn trades(&self, market: &Market, limit: Option<u32>) -> BoxFuture<'_, Result<Vec<Trade>>> {
let market = market.clone();
Box::pin(async move {
let connection = self.connect().await?;
rest::trades(&connection.http, &connection.universe, &market, limit).await
})
}
fn markets(&self, kind: MarketKind) -> BoxFuture<'_, Result<Vec<MarketInfo>>> {
Box::pin(async move {
let connection = self.connect().await?;
Ok(rest::markets(&connection.universe, kind))
})
}
fn order_book(&self, market: &Market, depth: Option<u32>) -> BoxFuture<'_, Result<OrderBook>> {
let market = market.clone();
Box::pin(async move {
let connection = self.connect().await?;
rest::order_book(&connection.http, &connection.universe, &market, depth).await
})
}
fn ticker(&self, market: &Market) -> BoxFuture<'_, Result<Ticker>> {
let market = market.clone();
Box::pin(async move {
let connection = self.connect().await?;
rest::ticker(&connection.http, &connection.universe, &market).await
})
}
fn candles(&self, request: &CandleRequest) -> BoxFuture<'_, Result<Vec<Candle>>> {
let request = request.clone();
Box::pin(async move {
let connection = self.connect().await?;
rest::candles(
&connection.http,
&connection.universe,
&request,
Timestamp::now(),
)
.await
})
}
fn subscribe(
&self,
subscription: &Subscription,
config: &StreamConfig,
) -> BoxFuture<'_, Result<MarketStream>> {
let subscription = subscription.clone();
let config = config.clone();
let url = self.websocket_url();
Box::pin(async move {
let connection = self.connect().await?;
let frames = stream::subscribe_frames(&subscription, &connection.universe)?;
let session = ws::connect(
WsConnect {
url: url.to_string(),
headers: None,
subscribe: WsConnect::fixed(frames),
heartbeat: Some(stream::HEARTBEAT),
},
&config,
)
.await?;
let close = session.close_handle();
let universe = connection.universe.clone();
let mut decoder = stream::Decoder::default();
Ok(MarketStream::new_with_close(
session.flat_map(move |command| {
futures_util::stream::iter(market_events(command, &universe, &mut decoder))
}),
move || async move { close.close().await },
))
})
}
fn balances(&self) -> BoxFuture<'_, Result<Vec<Balance>>> {
Box::pin(async move {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::balances(&connection.http, &user).await
})
}
fn deposits(&self, request: &TransferHistoryRequest) -> BoxFuture<'_, Result<Page<Deposit>>> {
let request = request.clone();
Box::pin(async move {
rest::validate_transfer_history(&request, Feature::DepositHistory)?;
let user = self.query_address()?;
let connection = self.connect().await?;
rest::deposits(&connection.http, &user, &request).await
})
}
fn withdrawals(
&self,
request: &TransferHistoryRequest,
) -> BoxFuture<'_, Result<Page<Withdrawal>>> {
let request = request.clone();
Box::pin(async move {
rest::validate_transfer_history(&request, Feature::WithdrawalHistory)?;
let user = self.query_address()?;
let connection = self.connect().await?;
rest::withdrawals(&connection.http, &user, &request).await
})
}
fn open_orders(&self, market: Option<&Market>) -> BoxFuture<'_, Result<Vec<Order>>> {
let market = market.cloned();
Box::pin(async move {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::open_orders(
&connection.http,
&connection.universe,
&user,
market.as_ref(),
)
.await
})
}
fn subscribe_account(&self, config: &StreamConfig) -> BoxFuture<'_, Result<AccountStream>> {
let config = config.clone();
let url = self.websocket_url();
Box::pin(async move {
let user = self.query_address()?;
let connection = self.connect().await?;
let session = ws::connect(
WsConnect {
url: url.to_string(),
headers: None,
subscribe: WsConnect::fixed(stream::account_subscribe_frames(&user)),
heartbeat: Some(stream::HEARTBEAT),
},
&config,
)
.await?;
let close = session.close_handle();
let universe = connection.universe.clone();
Ok(AccountStream::new_with_close(
session.flat_map(move |command| {
futures_util::stream::iter(account_events(command, &universe))
}),
move || async move { close.close().await },
))
})
}
fn place_order(&self, request: &OrderRequest) -> BoxFuture<'_, Result<Order>> {
let request = request.clone();
Box::pin(async move {
let private_key = self.signing_key()?;
let connection = self.connect().await?;
let _lane = SIGNED_ACTION_LANE.lock().await;
rest::place_order(
&connection.http,
&connection.universe,
private_key,
self.network,
&request,
self.next_nonce(Timestamp::now())?,
)
.await
})
}
fn cancel_order(&self, market: &Market, order_id: &str) -> BoxFuture<'_, Result<()>> {
let market = market.clone();
let order_id = order_id.to_string();
Box::pin(async move {
let private_key = self.signing_key()?;
let connection = self.connect().await?;
let _lane = SIGNED_ACTION_LANE.lock().await;
rest::cancel_order(
&connection.http,
&connection.universe,
private_key,
self.network,
&market,
&order_id,
self.next_nonce(Timestamp::now())?,
)
.await
})
}
fn positions(&self, market: Option<&Market>) -> BoxFuture<'_, Result<Vec<Position>>> {
let market = market.cloned();
Box::pin(async move {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::positions(
&connection.http,
&connection.universe,
&user,
market.as_ref(),
)
.await
})
}
fn margin_summary(&self) -> BoxFuture<'_, Result<MarginSummary>> {
Box::pin(async move {
let user = self.query_address()?;
let connection = self.connect().await?;
rest::margin_summary(&connection.http, &user).await
})
}
fn funding_rates(&self, request: &HistoryRequest) -> BoxFuture<'_, Result<Page<FundingRate>>> {
let request = request.clone();
Box::pin(async move {
rest::validate_page_limit(request.limit)?;
let connection = self.connect().await?;
rest::funding_rates(&connection.http, &connection.universe, &request).await
})
}
fn funding_payments(
&self,
request: &HistoryRequest,
) -> BoxFuture<'_, Result<Page<FundingPayment>>> {
let request = request.clone();
Box::pin(async move {
rest::validate_page_limit(request.limit)?;
let user = self.query_address()?;
let connection = self.connect().await?;
rest::funding_payments(&connection.http, &connection.universe, &user, &request).await
})
}
fn set_margin(&self, request: &MarginRequest) -> BoxFuture<'_, Result<()>> {
let request = request.clone();
Box::pin(async move {
let private_key = self.signing_key()?;
let connection = self.connect().await?;
let _lane = SIGNED_ACTION_LANE.lock().await;
rest::set_margin(
&connection.http,
&connection.universe,
private_key,
self.network,
&request,
self.next_nonce(Timestamp::now())?,
)
.await
})
}
}
fn market_events(
command: Result<WsCommand>,
universe: &Universe,
decoder: &mut stream::Decoder,
) -> Vec<Result<MarketEvent>> {
let text = match command {
Ok(WsCommand::Text(text)) => text,
Ok(WsCommand::Binary(bytes)) => match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => return vec![Err(Error::decode(format!("frame is not UTF-8: {err}")))],
},
Ok(WsCommand::Reconnected) => {
decoder.reconnected();
return vec![Ok(MarketEvent::Reconnected)];
}
Err(err) => return vec![Err(err)],
};
match decoder.decode(&text, universe, Timestamp::now()) {
Ok(events) => events.into_iter().map(Ok).collect(),
Err(err) => vec![Err(err)],
}
}
fn account_events(command: Result<WsCommand>, universe: &Universe) -> Vec<Result<AccountEvent>> {
let text = match command {
Ok(WsCommand::Text(text)) => text,
Ok(WsCommand::Binary(bytes)) => match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => return vec![Err(Error::decode(format!("frame is not UTF-8: {err}")))],
},
Ok(WsCommand::Reconnected) => return vec![Ok(AccountEvent::Reconnected)],
Err(err) => return vec![Err(err)],
};
match stream::decode_account(&text, universe) {
Ok(events) => events.into_iter().map(Ok).collect(),
Err(err) => vec![Err(err)],
}
}
fn detailed_market_events(
command: Result<WsCommand>,
universe: &Universe,
decoder: &mut stream::DetailedDecoder,
) -> Vec<Result<HyperliquidMarketEvent>> {
let text = match command {
Ok(WsCommand::Text(text)) => text,
Ok(WsCommand::Binary(bytes)) => match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => return vec![Err(Error::decode(format!("frame is not UTF-8: {err}")))],
},
Ok(WsCommand::Reconnected) => {
decoder.reconnected();
return vec![Ok(HyperliquidMarketEvent::Reconnected)];
}
Err(err) => return vec![Err(err)],
};
match decoder.decode(&text, universe, Timestamp::now()) {
Ok(events) => events.into_iter().map(Ok).collect(),
Err(err) => vec![Err(err)],
}
}
fn detailed_account_events(
command: Result<WsCommand>,
universe: &Universe,
) -> Vec<Result<HyperliquidAccountEvent>> {
let text = match command {
Ok(WsCommand::Text(text)) => text,
Ok(WsCommand::Binary(bytes)) => match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => return vec![Err(Error::decode(format!("frame is not UTF-8: {err}")))],
},
Ok(WsCommand::Reconnected) => return vec![Ok(HyperliquidAccountEvent::Reconnected)],
Err(err) => return vec![Err(err)],
};
match stream::decode_detailed_account(&text, universe) {
Ok(events) => events.into_iter().map(Ok).collect(),
Err(err) => vec![Err(err)],
}
}
#[cfg(test)]
mod tests {
use super::*;
use parse::tests::universe;
fn candle_frame(open_ms: i64) -> String {
format!(
r#"{{"channel":"candle","data":{{"T":{},"c":100,"h":100,"i":"1m","l":100,"n":12,"o":100,"s":"BTC","t":{},"v":1}}}}"#,
open_ms + 59_999,
open_ms
)
}
#[test]
fn a_reconnect_drops_the_held_window_instead_of_settling_it_across_the_gap() {
const WINDOW_ONE_MS: i64 = 1_785_397_500_000;
const WINDOW_TWO_MS: i64 = 1_785_397_560_000;
let universe = universe();
let mut decoder = stream::Decoder::default();
let text = |frame: String| Ok(WsCommand::Text(frame));
let events = market_events(text(candle_frame(WINDOW_ONE_MS)), &universe, &mut decoder);
assert_eq!(events.len(), 1, "the first frame of a window is forming");
let events = market_events(Ok(WsCommand::Reconnected), &universe, &mut decoder);
assert!(matches!(events.as_slice(), [Ok(MarketEvent::Reconnected)]));
let events = market_events(text(candle_frame(WINDOW_TWO_MS)), &universe, &mut decoder);
assert_eq!(
events.len(),
1,
"a window from before the gap is not settled by a frame after it: {events:?}"
);
let [Ok(MarketEvent::Candle(forming))] = events.as_slice() else {
panic!("expected one forming candle: {events:?}");
};
assert!(!forming.closed);
assert_eq!(forming.open_time, Timestamp::from_millis(WINDOW_TWO_MS));
let events = market_events(
text(candle_frame(WINDOW_TWO_MS + 60_000)),
&universe,
&mut decoder,
);
assert_eq!(events.len(), 2, "{events:?}");
let [Ok(MarketEvent::Candle(settled)), _] = events.as_slice() else {
panic!("expected a settled window: {events:?}");
};
assert!(settled.closed);
assert_eq!(settled.open_time, Timestamp::from_millis(WINDOW_TWO_MS));
}
#[test]
fn detailed_reconnect_clears_native_candle_state_and_reports_the_gap() {
const WINDOW_ONE_MS: i64 = 1_785_397_500_000;
const WINDOW_TWO_MS: i64 = 1_785_397_560_000;
let universe = universe();
let mut decoder = stream::DetailedDecoder::default();
let text = |frame: String| Ok(WsCommand::Text(frame));
let events =
detailed_market_events(text(candle_frame(WINDOW_ONE_MS)), &universe, &mut decoder);
assert_eq!(events.len(), 1);
let events = detailed_market_events(Ok(WsCommand::Reconnected), &universe, &mut decoder);
assert!(matches!(
events.as_slice(),
[Ok(HyperliquidMarketEvent::Reconnected)]
));
let events =
detailed_market_events(text(candle_frame(WINDOW_TWO_MS)), &universe, &mut decoder);
let [Ok(HyperliquidMarketEvent::Candle(forming))] = events.as_slice() else {
panic!("expected only the post-reconnect forming candle: {events:?}");
};
assert!(!forming.common.closed);
assert_eq!(
forming.common.open_time,
Timestamp::from_millis(WINDOW_TWO_MS)
);
let events = detailed_account_events(Ok(WsCommand::Reconnected), &universe);
assert!(matches!(
events.as_slice(),
[Ok(HyperliquidAccountEvent::Reconnected)]
));
}
#[test]
fn trades_are_served_both_live_and_over_rest() {
let adapter = HyperliquidAdapter::new();
assert!(adapter.supports(Feature::TradeStream));
assert!(adapter.supports(Feature::Trades));
}
#[test]
fn one_adapter_serves_both_spot_and_perpetual_markets() {
let adapter = HyperliquidAdapter::new().with_wallet("0xabc", "0xdef");
for feature in [
Feature::Positions,
Feature::Margin,
Feature::FundingRates,
Feature::MarginConfig,
Feature::Trading,
] {
assert!(adapter.supports(feature), "{feature:?}");
}
}
#[test]
fn query_address_and_signer_unlock_only_the_operations_they_feed() {
let public = HyperliquidAdapter::new();
let address_only = HyperliquidAdapter::new()
.with_query_address("0x14791697260e4c9a71f18484c9f997b308e59325");
let signer_only = HyperliquidAdapter::new()
.with_signer("0x0123456789012345678901234567890123456789012345678901234567890123");
for feature in [
Feature::Balances,
Feature::DepositHistory,
Feature::WithdrawalHistory,
Feature::Positions,
] {
assert!(!public.supports(feature), "{feature:?}");
assert!(address_only.supports(feature), "{feature:?}");
assert!(!signer_only.supports(feature), "{feature:?}");
}
for feature in [
Feature::Trading,
Feature::MarginConfig,
Feature::ReduceOnlyOrders,
] {
assert!(!public.supports(feature), "{feature:?}");
assert!(!address_only.supports(feature), "{feature:?}");
assert!(signer_only.supports(feature), "{feature:?}");
}
for feature in [
Feature::AssetNetworks,
Feature::DepositAddresses,
Feature::WithdrawalQuotes,
Feature::Withdrawals,
] {
assert!(!address_only.supports(feature), "{feature:?}");
assert!(!signer_only.supports(feature), "{feature:?}");
}
}
#[test]
fn the_signing_key_never_appears_in_debug_output() {
let adapter = HyperliquidAdapter::new().with_wallet("0xabc", "0xdeadbeef");
let rendered = format!("{adapter:?}");
assert!(!rendered.contains("0xdeadbeef"));
assert!(rendered.contains("<redacted>"));
assert!(rendered.contains("0xabc"));
}
#[tokio::test]
async fn a_private_call_without_a_wallet_says_so_before_it_reaches_the_network() {
let public = HyperliquidAdapter::new();
let market = Market::perpetual(Exchange::Hyperliquid, "BTC", "USDC");
assert!(matches!(public.balances().await, Err(Error::Auth { .. })));
assert!(matches!(
public.positions(None).await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.margin_summary().await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.cancel_order(&market, "1").await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.non_funding_ledger(None, None, None, None).await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.user_rate_limit().await,
Err(Error::Auth { .. })
));
assert!(matches!(public.user_role().await, Err(Error::Auth { .. })));
assert!(matches!(public.referral().await, Err(Error::Auth { .. })));
assert!(matches!(public.user_fees().await, Err(Error::Auth { .. })));
assert!(matches!(public.portfolio().await, Err(Error::Auth { .. })));
assert!(matches!(
public.sub_accounts().await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.user_vault_equities().await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.user_fills(false).await,
Err(Error::Auth { .. })
));
assert!(matches!(
public
.user_fills_by_time(Timestamp::from_millis(0), None, false)
.await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.user_funding(Timestamp::from_millis(0), None).await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.spot_clearinghouse_state().await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.basic_open_orders().await,
Err(Error::Auth { .. })
));
assert!(matches!(
public
.order_status(HyperliquidOrderReference::order_id(1))
.await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.historical_orders().await,
Err(Error::Auth { .. })
));
}
#[tokio::test]
async fn order_status_preflight_checks_address_then_reference_before_connecting() {
let missing_address = HyperliquidAdapter::new();
assert!(matches!(
missing_address
.order_status(HyperliquidOrderReference::client_order_id("not-a-cloid"))
.await,
Err(Error::Auth { .. })
));
let configured = HyperliquidAdapter::new()
.with_query_address("0x14791697260e4c9a71f18484c9f997b308e59325");
let invalid = configured
.order_status(HyperliquidOrderReference::client_order_id("not-a-cloid"))
.await;
assert!(
matches!(&invalid, Err(Error::InvalidRequest { field, .. }) if *field == "client_order_id"),
"{invalid:?}"
);
}
#[tokio::test]
async fn a_zero_funding_payment_limit_is_rejected_before_authentication() {
let public = HyperliquidAdapter::new();
let request =
HistoryRequest::new(Market::perpetual(Exchange::Hyperliquid, "BTC", "USDC")).limit(0);
let refused = public.funding_payments(&request).await;
assert!(
matches!(&refused, Err(Error::InvalidRequest { field, .. }) if *field == "limit"),
"{refused:?}"
);
}
#[tokio::test]
async fn provider_history_ranges_are_checked_before_connecting() {
let public = HyperliquidAdapter::new();
let market = Market::perpetual(Exchange::Hyperliquid, "BTC", "USDC");
let later = Timestamp::from_millis(2);
let earlier = Timestamp::from_millis(1);
assert!(matches!(
public.candle_snapshot(&market, "1m", later, Some(earlier)).await,
Err(Error::InvalidRequest { field, .. }) if field == "to"
));
assert!(matches!(
public.funding_history(&market, later, Some(earlier)).await,
Err(Error::InvalidRequest { field, .. }) if field == "to"
));
assert!(matches!(
public.user_funding(later, Some(earlier)).await,
Err(Error::InvalidRequest { field, .. }) if field == "to"
));
}
#[tokio::test]
async fn a_zero_funding_rate_limit_is_rejected_on_the_first_poll() {
let public = HyperliquidAdapter::new();
let request =
HistoryRequest::new(Market::perpetual(Exchange::Hyperliquid, "BTC", "USDC")).limit(0);
let mut call = public.funding_rates(&request);
let waker = futures_util::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
let first_poll = std::future::Future::poll(call.as_mut(), &mut context);
assert!(matches!(
first_poll,
std::task::Poll::Ready(Err(Error::InvalidRequest { field, .. })) if field == "limit"
));
}
#[tokio::test]
async fn a_zero_ledger_limit_is_rejected_before_authentication() {
let public = HyperliquidAdapter::new();
let refused = public.non_funding_ledger(None, None, None, Some(0)).await;
assert!(
matches!(&refused, Err(Error::InvalidRequest { field, .. }) if *field == "limit"),
"{refused:?}"
);
}
#[tokio::test]
async fn an_invalid_signer_is_rejected_before_the_network() {
let broken = HyperliquidAdapter::new().with_signer("not-a-key");
let market = Market::perpetual(Exchange::Hyperliquid, "BTC", "USDC");
assert!(matches!(
broken.cancel_order(&market, "1").await,
Err(Error::Auth { .. })
));
}
#[test]
fn independent_adapters_share_a_monotonic_nonce_allocator() {
let first = HyperliquidAdapter::new();
let second = HyperliquidAdapter::new();
let now = Timestamp::from_millis(1_700_000_000_123);
let first_nonce = first.next_nonce(now).unwrap();
let second_nonce = second.next_nonce(now).unwrap();
assert!(second_nonce > first_nonce);
}
#[tokio::test]
async fn transfer_history_filters_that_the_ledger_cannot_prove_are_rejected_offline() {
let request = TransferHistoryRequest::new().network(crate::types::Network::Arbitrum);
let adapter = HyperliquidAdapter::new()
.with_query_address("0x14791697260e4c9a71f18484c9f997b308e59325");
assert!(matches!(
adapter.deposits(&request).await,
Err(Error::Unsupported {
feature: Feature::DepositHistory,
..
})
));
assert!(matches!(
adapter.withdrawals(&request).await,
Err(Error::Unsupported {
feature: Feature::WithdrawalHistory,
..
})
));
}
#[tokio::test]
async fn deposit_address_stays_unsupported_instead_of_guessing_a_bridge() {
let adapter = HyperliquidAdapter::new()
.with_query_address("0x14791697260e4c9a71f18484c9f997b308e59325");
let request =
crate::request::DepositAddressRequest::new("USDC", crate::types::Network::Arbitrum);
assert!(matches!(
adapter.deposit_address(&request).await,
Err(Error::Unsupported {
feature: Feature::DepositAddresses,
..
})
));
}
#[test]
fn testnet_and_mainnet_are_separate_hosts() {
let mainnet = HyperliquidAdapter::new();
let testnet = HyperliquidAdapter::testnet();
assert!(!mainnet.is_testnet());
assert!(testnet.is_testnet());
assert_ne!(mainnet.rest_base_url(), testnet.rest_base_url());
assert_ne!(mainnet.websocket_url(), testnet.websocket_url());
}
}