From 43f0e517649f43b92226a8f53b2a0e5e9b5e03c1 Mon Sep 17 00:00:00 2001 From: InTheta <17220028+InTheta@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:40:44 +0200 Subject: [PATCH] Add multi-user clearinghouse state helper --- src/info/info_client.rs | 31 +++++++++++++++---- tests/batch_clearinghouse_states.rs | 48 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 tests/batch_clearinghouse_states.rs diff --git a/src/info/info_client.rs b/src/info/info_client.rs index b3d8ba2..920f922 100644 --- a/src/info/info_client.rs +++ b/src/info/info_client.rs @@ -36,10 +36,6 @@ pub enum InfoRequest { UserState { user: Address, }, - #[serde(rename = "batchClearinghouseStates")] - UserStates { - users: Vec
, - }, #[serde(rename = "spotClearinghouseState")] UserTokenBalances { user: Address, @@ -192,9 +188,17 @@ impl InfoClient { self.send_info_request(input).await } + /// Fetches clearinghouse state for multiple users. + /// + /// Hyperliquid's public `/info` API does not currently expose a server-side + /// batch clearinghouse endpoint, so this helper calls `clearinghouseState` + /// once per address and returns responses in request order. pub async fn user_states(&self, addresses: Vec
) -> Result> { - let input = InfoRequest::UserStates { users: addresses }; - self.send_info_request(input).await + let mut states = Vec::with_capacity(addresses.len()); + for address in addresses { + states.push(self.user_state(address).await?); + } + Ok(states) } pub async fn user_token_balances(&self, address: Address) -> Result { @@ -321,3 +325,18 @@ impl InfoClient { self.send_info_request(input).await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clearinghouse_state_serializes_single_user_request() { + for user in [Address::ZERO, Address::from([1u8; 20])] { + let json = serde_json::to_value(InfoRequest::UserState { user }).unwrap(); + assert_eq!(json["type"], "clearinghouseState"); + assert_eq!(json["user"], serde_json::to_value(user).unwrap()); + assert!(json.get("users").is_none()); + } + } +} diff --git a/tests/batch_clearinghouse_states.rs b/tests/batch_clearinghouse_states.rs new file mode 100644 index 0000000..fb3b756 --- /dev/null +++ b/tests/batch_clearinghouse_states.rs @@ -0,0 +1,48 @@ +//! Integration test: user_states() fetches clearinghouseState for multiple users. +//! +//! Hyperliquid's public /info API currently returns null for a +//! batchClearinghouseStates request, so user_states() is intentionally a +//! client-side helper over the supported clearinghouseState request. +//! +//! Run with: +//! HYPERLIQUID_TEST_LIVE=1 HYPERLIQUID_TEST_USERS=0x...,0x... cargo test test_user_states_live --test batch_clearinghouse_states -- --ignored + +use alloy::primitives::Address; +use hyperliquid_rust_sdk::{BaseUrl, InfoClient}; + +#[tokio::test] +#[ignore = "hits live API; run with HYPERLIQUID_TEST_LIVE=1 cargo test --test batch_clearinghouse_states -- --ignored"] +async fn test_user_states_live() { + if std::env::var("HYPERLIQUID_TEST_LIVE").ok().as_deref() != Some("1") { + eprintln!("Skipping live test (set HYPERLIQUID_TEST_LIVE=1 to run)"); + return; + } + let users = std::env::var("HYPERLIQUID_TEST_USERS") + .expect("set HYPERLIQUID_TEST_USERS to a comma-separated list of 0x addresses"); + let addrs: Vec
= users + .split(',') + .map(|user| user.trim().parse().expect("valid address")) + .collect(); + assert!(!addrs.is_empty(), "provide at least one test user"); + + let client = InfoClient::new(None, Some(BaseUrl::Mainnet)) + .await + .expect("InfoClient::new"); + let states = client + .user_states(addrs.clone()) + .await + .expect("user_states"); + assert_eq!( + states.len(), + addrs.len(), + "user_states returns one clearinghouse state per user in request order" + ); + for (i, state) in states.iter().enumerate() { + let account_value = state + .margin_summary + .account_value + .parse::() + .expect("account_value should parse as f64"); + assert!(account_value >= 0.0, "user {} has valid margin_summary", i); + } +}