Skip to main content

guilder_client_hyperliquid/
client.rs

1use alloy_primitives::Address;
2use guilder_abstraction::{self, L2Update, Fill, AssetContext, PredictedFunding, Liquidation, BoxStream, Side, OrderSide, OrderStatus, OrderType, TimeInForce, OrderPlacement, Position, OpenOrder, UserFill, OrderUpdate, FundingPayment, Deposit, Withdrawal};
3use futures_util::{stream, SinkExt, StreamExt};
4use reqwest::Client;
5use rust_decimal::Decimal;
6use serde::Deserialize;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::str::FromStr;
10use tokio_tungstenite::{connect_async, tungstenite::Message};
11
12const HYPERLIQUID_INFO_URL: &str = "https://api.hyperliquid.xyz/info";
13const HYPERLIQUID_EXCHANGE_URL: &str = "https://api.hyperliquid.xyz/exchange";
14const HYPERLIQUID_WS_URL: &str = "wss://api.hyperliquid.xyz/ws";
15
16async fn parse_response<T: for<'de> serde::Deserialize<'de>>(resp: reqwest::Response) -> Result<T, String> {
17    let text = resp.text().await.map_err(|e| e.to_string())?;
18    serde_json::from_str(&text).map_err(|e| format!("{e}: {text}"))
19}
20
21pub struct HyperliquidClient {
22    client: Client,
23    user_address: Option<Address>,
24    private_key: Option<String>,
25}
26
27impl HyperliquidClient {
28    pub fn new() -> Self {
29        HyperliquidClient { client: Client::new(), user_address: None, private_key: None }
30    }
31
32    pub fn with_auth(user_address: Address, private_key: String) -> Self {
33        HyperliquidClient { client: Client::new(), user_address: Some(user_address), private_key: Some(private_key) }
34    }
35
36    fn require_user_address(&self) -> Result<String, String> {
37        self.user_address
38            .map(|a| format!("{:#x}", a))
39            .ok_or_else(|| "user address required: use HyperliquidClient::with_auth".to_string())
40    }
41
42    fn require_private_key(&self) -> Result<&str, String> {
43        self.private_key.as_deref().ok_or_else(|| "private key required: use HyperliquidClient::with_auth".to_string())
44    }
45
46    async fn get_asset_index(&self, symbol: &str) -> Result<usize, String> {
47        let resp = self.client
48            .post(HYPERLIQUID_INFO_URL)
49            .json(&serde_json::json!({"type": "meta"}))
50            .send()
51            .await
52            .map_err(|e| e.to_string())?;
53        let meta: MetaResponse = parse_response(resp).await?;
54        meta.universe.iter()
55            .position(|a| a.name == symbol)
56            .ok_or_else(|| format!("symbol {} not found", symbol))
57    }
58
59    async fn submit_signed_action(&self, action: Value, vault_address: Option<&str>) -> Result<Value, String> {
60        let private_key = self.require_private_key()?;
61        let nonce = std::time::SystemTime::now()
62            .duration_since(std::time::UNIX_EPOCH)
63            .unwrap()
64            .as_millis() as u64;
65
66        let (r, s, v) = sign_action(private_key, &action, vault_address, nonce)?;
67
68        let payload = serde_json::json!({
69            "action": action,
70            "nonce": nonce,
71            "signature": {"r": r, "s": s, "v": v},
72            "vaultAddress": null
73        });
74
75        let resp = self.client
76            .post(HYPERLIQUID_EXCHANGE_URL)
77            .json(&payload)
78            .send()
79            .await
80            .map_err(|e| e.to_string())?;
81
82        let body: Value = parse_response(resp).await?;
83        if body["status"].as_str() == Some("err") {
84            return Err(body["response"].as_str().unwrap_or("unknown error").to_string());
85        }
86        Ok(body)
87    }
88}
89
90// --- REST deserialization types ---
91
92#[derive(Deserialize)]
93struct MetaResponse {
94    universe: Vec<AssetInfo>,
95}
96
97#[derive(Deserialize)]
98struct AssetInfo {
99    name: String,
100}
101
102type MetaAndAssetCtxsResponse = (MetaResponse, Vec<RestAssetCtx>);
103
104#[derive(Deserialize)]
105#[serde(rename_all = "camelCase")]
106#[allow(dead_code)]
107struct RestAssetCtx {
108    open_interest: String,
109    funding: String,
110    mark_px: String,
111    day_ntl_vlm: String,
112    mid_px: Option<String>,
113    oracle_px: Option<String>,
114    premium: Option<String>,
115    prev_day_px: Option<String>,
116}
117
118#[derive(Deserialize)]
119#[serde(rename_all = "camelCase")]
120struct ClearinghouseStateResponse {
121    margin_summary: MarginSummary,
122    asset_positions: Vec<AssetPosition>,
123}
124
125#[derive(Deserialize)]
126#[serde(rename_all = "camelCase")]
127struct MarginSummary {
128    account_value: String,
129}
130
131#[derive(Deserialize)]
132struct AssetPosition {
133    position: PositionDetail,
134}
135
136#[derive(Deserialize)]
137#[serde(rename_all = "camelCase")]
138struct PositionDetail {
139    coin: String,
140    /// positive = long, negative = short
141    szi: String,
142    entry_px: Option<String>,
143}
144
145#[derive(Deserialize)]
146#[serde(rename_all = "camelCase")]
147struct RestOpenOrder {
148    coin: String,
149    side: String,
150    limit_px: String,
151    sz: String,
152    oid: i64,
153    orig_sz: String,
154}
155
156// predictedFundings response: Vec<(coin, Vec<(venue, entry_or_null)>)>
157// The API returns null for venues that don't list the coin.
158type PredictedFundingsResponse = Vec<(String, Vec<(String, Option<PredictedFundingEntry>)>)>;
159
160#[derive(Deserialize)]
161#[serde(rename_all = "camelCase")]
162struct PredictedFundingEntry {
163    funding_rate: String,
164    next_funding_time: i64,
165}
166
167// --- WebSocket envelope and data shapes ---
168
169#[derive(Deserialize)]
170struct WsEnvelope {
171    channel: String,
172    #[serde(default)]
173    data: Value,
174}
175
176#[derive(Deserialize)]
177struct WsBook {
178    coin: String,
179    levels: Vec<Vec<WsLevel>>,
180    time: i64,
181}
182
183#[derive(Deserialize)]
184struct WsLevel {
185    px: String,
186    sz: String,
187}
188
189#[derive(Deserialize)]
190#[serde(rename_all = "camelCase")]
191struct WsAssetCtx {
192    coin: String,
193    ctx: WsPerpsCtx,
194}
195
196#[derive(Deserialize)]
197#[serde(rename_all = "camelCase")]
198struct WsPerpsCtx {
199    open_interest: String,
200    funding: String,
201    mark_px: String,
202    day_ntl_vlm: String,
203    mid_px: Option<String>,
204    oracle_px: Option<String>,
205    premium: Option<String>,
206    prev_day_px: Option<String>,
207}
208
209#[derive(Deserialize)]
210struct WsUserEvent {
211    liquidation: Option<WsLiquidation>,
212    fills: Option<Vec<WsUserFill>>,
213    funding: Option<WsFunding>,
214}
215
216#[derive(Deserialize)]
217struct WsLiquidation {
218    liquidated_user: String,
219    liquidated_ntl_pos: String,
220    liquidated_account_value: String,
221}
222
223#[derive(Deserialize)]
224struct WsUserFill {
225    coin: String,
226    px: String,
227    sz: String,
228    side: String,
229    time: i64,
230    oid: i64,
231    fee: String,
232}
233
234#[derive(Deserialize)]
235struct WsFunding {
236    time: i64,
237    coin: String,
238    usdc: String,
239}
240
241#[derive(Deserialize)]
242struct WsTrade {
243    coin: String,
244    side: String,
245    px: String,
246    sz: String,
247    time: i64,
248    tid: i64,
249}
250
251#[derive(Deserialize)]
252struct WsOrderUpdate {
253    order: WsOrderInfo,
254    status: String,
255    #[serde(rename = "statusTimestamp")]
256    status_timestamp: i64,
257}
258
259#[derive(Deserialize)]
260#[serde(rename_all = "camelCase")]
261struct WsOrderInfo {
262    coin: String,
263    side: String,
264    limit_px: String,
265    sz: String,
266    oid: i64,
267    orig_sz: String,
268}
269
270// --- WebSocket ledger update shapes (deposits / withdrawals) ---
271
272#[derive(Deserialize)]
273struct WsLedgerUpdates {
274    updates: Vec<WsLedgerEntry>,
275}
276
277#[derive(Deserialize)]
278struct WsLedgerEntry {
279    time: i64,
280    delta: WsLedgerDelta,
281}
282
283#[derive(Deserialize)]
284struct WsLedgerDelta {
285    #[serde(rename = "type")]
286    kind: String,
287    usdc: Option<String>,
288}
289
290// --- Helpers ---
291
292fn parse_decimal(s: &str) -> Option<Decimal> {
293    Decimal::from_str(s).ok()
294}
295
296fn keccak256(data: &[u8]) -> [u8; 32] {
297    use sha3::{Digest, Keccak256};
298    Keccak256::digest(data).into()
299}
300
301/// EIP-712 domain separator for Hyperliquid mainnet (Arbitrum, chainId=42161).
302fn hyperliquid_domain_separator() -> [u8; 32] {
303    let type_hash = keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
304    let name_hash = keccak256(b"Exchange");
305    let version_hash = keccak256(b"1");
306    let mut chain_id = [0u8; 32];
307    chain_id[28..32].copy_from_slice(&42161u32.to_be_bytes());
308    let verifying_contract = [0u8; 32];
309
310    let mut data = [0u8; 160];
311    data[..32].copy_from_slice(&type_hash);
312    data[32..64].copy_from_slice(&name_hash);
313    data[64..96].copy_from_slice(&version_hash);
314    data[96..128].copy_from_slice(&chain_id);
315    data[128..160].copy_from_slice(&verifying_contract);
316    keccak256(&data)
317}
318
319/// Signs a Hyperliquid exchange action using EIP-712.
320/// Returns (r, s, v) where r and s are "0x"-prefixed hex strings and v is 27 or 28.
321fn sign_action(private_key: &str, action: &Value, vault_address: Option<&str>, nonce: u64) -> Result<(String, String, u8), String> {
322    use k256::ecdsa::SigningKey;
323
324    // Step 1: msgpack-encode the action, append nonce + vault flag
325    let msgpack_bytes = rmp_serde::to_vec(action).map_err(|e| e.to_string())?;
326    let mut data = msgpack_bytes;
327    data.extend_from_slice(&nonce.to_be_bytes());
328    match vault_address {
329        None => data.push(0u8),
330        Some(addr) => {
331            data.push(1u8);
332            let addr_bytes = hex::decode(addr.trim_start_matches("0x"))
333                .map_err(|e| format!("invalid vault address: {}", e))?;
334            data.extend_from_slice(&addr_bytes);
335        }
336    }
337    let connection_id = keccak256(&data);
338
339    // Step 2: hash the Agent struct
340    let agent_type_hash = keccak256(b"Agent(string source,bytes32 connectionId)");
341    let source_hash = keccak256(b"a"); // "a" = mainnet
342    let mut struct_data = [0u8; 96];
343    struct_data[..32].copy_from_slice(&agent_type_hash);
344    struct_data[32..64].copy_from_slice(&source_hash);
345    struct_data[64..96].copy_from_slice(&connection_id);
346    let struct_hash = keccak256(&struct_data);
347
348    // Step 3: EIP-712 final hash
349    let domain_sep = hyperliquid_domain_separator();
350    let mut final_data = Vec::with_capacity(66);
351    final_data.extend_from_slice(b"\x19\x01");
352    final_data.extend_from_slice(&domain_sep);
353    final_data.extend_from_slice(&struct_hash);
354    let final_hash = keccak256(&final_data);
355
356    // Step 4: sign with secp256k1
357    let key_bytes = hex::decode(private_key.trim_start_matches("0x"))
358        .map_err(|e| format!("invalid private key: {}", e))?;
359    let signing_key = SigningKey::from_bytes(key_bytes.as_slice().into())
360        .map_err(|e| e.to_string())?;
361    let (sig, recovery_id) = signing_key.sign_prehash_recoverable(&final_hash)
362        .map_err(|e| e.to_string())?;
363
364    let sig_bytes = sig.to_bytes();
365    let r = format!("0x{}", hex::encode(&sig_bytes[..32]));
366    let s = format!("0x{}", hex::encode(&sig_bytes[32..64]));
367    let v = 27u8 + recovery_id.to_byte();
368
369    Ok((r, s, v))
370}
371
372/// Generic WebSocket subscription helper.
373///
374/// Connects to the Hyperliquid WS endpoint, sends `subscription`, then drives the read loop:
375/// - Replies to server `Ping` frames with `Pong`.
376/// - Sends an application-level `{"method":"ping"}` every 50 s to keep the connection alive.
377/// - Surfaces connect / send / protocol errors as `Err` items, then **reconnects after 5 s**.
378/// - Calls `parse(envelope)` for each `Text` message; non-empty `Vec`s become `Ok` items.
379///   Non-envelope messages (e.g. pong responses) are silently skipped.
380fn ws_subscribe<T, F>(subscription: Value, mut parse: F) -> BoxStream<Result<T, String>>
381where
382    T: Send + 'static,
383    F: FnMut(WsEnvelope) -> Vec<T> + Send + 'static,
384{
385    Box::pin(async_stream::stream! {
386        let mut backoff_secs: u64 = 1;
387        loop {
388            let ws = match connect_async(HYPERLIQUID_WS_URL).await {
389                Ok((ws, _)) => ws,
390                Err(e) => {
391                    yield Err(format!("ws connect failed: {e} — reconnecting in {backoff_secs}s"));
392                    tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
393                    backoff_secs = (backoff_secs * 2).min(60);
394                    continue;
395                }
396            };
397            let (mut sink, mut stream) = ws.split();
398            if let Err(e) = sink.send(Message::Text(subscription.to_string().into())).await {
399                yield Err(format!("ws subscribe failed: {e} — reconnecting in {backoff_secs}s"));
400                tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
401                backoff_secs = (backoff_secs * 2).min(60);
402                continue;
403            }
404            // Connected successfully — reset backoff
405            backoff_secs = 1;
406            let mut ping_interval = tokio::time::interval_at(
407                tokio::time::Instant::now() + std::time::Duration::from_secs(50),
408                std::time::Duration::from_secs(50),
409            );
410            let should_reconnect;
411            loop {
412                tokio::select! {
413                    _ = ping_interval.tick() => {
414                        if let Err(e) = sink.send(Message::Text(r#"{"method":"ping"}"#.to_string().into())).await {
415                            yield Err(format!("ws ping failed: {e} — reconnecting in {backoff_secs}s"));
416                            should_reconnect = true;
417                            break;
418                        }
419                    }
420                    msg = stream.next() => {
421                        match msg {
422                            None => {
423                                yield Err(format!("ws stream ended — reconnecting in {backoff_secs}s"));
424                                should_reconnect = true;
425                                break;
426                            }
427                            Some(Err(e)) => {
428                                yield Err(format!("ws error: {e} — reconnecting in {backoff_secs}s"));
429                                should_reconnect = true;
430                                break;
431                            }
432                            Some(Ok(Message::Ping(data))) => { let _ = sink.send(Message::Pong(data)).await; }
433                            Some(Ok(Message::Close(_))) => {
434                                yield Err(format!("websocket closed — reconnecting in {backoff_secs}s"));
435                                should_reconnect = true;
436                                break;
437                            }
438                            Some(Ok(Message::Text(text))) => {
439                                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
440                                    yield Err(format!("unexpected ws message: {text}"));
441                                    continue;
442                                };
443                                match env.channel.as_str() {
444                                    "pong" | "subscriptionResponse" => {}
445                                    _ => {
446                                        for item in parse(env) {
447                                            yield Ok(item);
448                                        }
449                                    }
450                                }
451                            }
452                            Some(Ok(_)) => {}
453                        }
454                    }
455                }
456            }
457            if should_reconnect {
458                tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
459                backoff_secs = (backoff_secs * 2).min(60);
460            }
461        }
462    })
463}
464
465// --- Trait implementations ---
466
467#[allow(async_fn_in_trait)]
468impl guilder_abstraction::TestServer for HyperliquidClient {
469    /// Sends a lightweight allMids request; returns true if the server responds 200 OK.
470    async fn ping(&self) -> Result<bool, String> {
471        self.client
472            .post(HYPERLIQUID_INFO_URL)
473            .json(&serde_json::json!({"type": "allMids"}))
474            .send()
475            .await
476            .map(|r| r.status().is_success())
477            .map_err(|e| e.to_string())
478    }
479
480    /// Hyperliquid has no dedicated server-time endpoint; returns local UTC ms.
481    async fn get_server_time(&self) -> Result<i64, String> {
482        Ok(std::time::SystemTime::now()
483            .duration_since(std::time::UNIX_EPOCH)
484            .map(|d| d.as_millis() as i64)
485            .unwrap_or(0))
486    }
487}
488
489#[allow(async_fn_in_trait)]
490impl guilder_abstraction::GetMarketData for HyperliquidClient {
491    /// Returns all perpetual asset names from Hyperliquid's meta endpoint.
492    async fn get_symbol(&self) -> Result<Vec<String>, String> {
493        let resp = self.client
494            .post(HYPERLIQUID_INFO_URL)
495            .json(&serde_json::json!({"type": "meta"}))
496            .send()
497            .await
498            .map_err(|e| e.to_string())?;
499        parse_response::<MetaResponse>(resp).await
500            .map(|r| r.universe.into_iter().map(|a| a.name).collect())
501    }
502
503    /// Returns the current open interest for `symbol` from metaAndAssetCtxs.
504    async fn get_open_interest(&self, symbol: String) -> Result<Decimal, String> {
505        let resp = self.client
506            .post(HYPERLIQUID_INFO_URL)
507            .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
508            .send()
509            .await
510            .map_err(|e| e.to_string())?;
511        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
512            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
513        meta.universe.iter()
514            .position(|a| a.name == symbol)
515            .and_then(|i| ctxs.get(i))
516            .and_then(|ctx| parse_decimal(&ctx.open_interest))
517            .ok_or_else(|| format!("symbol {} not found", symbol))
518    }
519
520    /// Returns a full AssetContext snapshot for `symbol` from metaAndAssetCtxs.
521    async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
522        let resp = self.client
523            .post(HYPERLIQUID_INFO_URL)
524            .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
525            .send()
526            .await
527            .map_err(|e| e.to_string())?;
528        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
529            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
530        let idx = meta.universe.iter()
531            .position(|a| a.name == symbol)
532            .ok_or_else(|| format!("symbol {} not found", symbol))?;
533        let ctx = ctxs.get(idx).ok_or_else(|| format!("symbol {} not found", symbol))?;
534        Ok(AssetContext {
535            symbol,
536            open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
537            funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
538            mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
539            day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
540            mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
541            oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
542            premium: ctx.premium.as_deref().and_then(parse_decimal),
543            prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
544        })
545    }
546
547    /// Fetches metaAndAssetCtxs once and returns all asset contexts in universe order.
548    /// Prefer this over repeated `get_asset_context` calls to avoid rate-limiting.
549    async fn get_all_asset_contexts(&self) -> Result<Vec<AssetContext>, String> {
550        let resp = self.client
551            .post(HYPERLIQUID_INFO_URL)
552            .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
553            .send()
554            .await
555            .map_err(|e| e.to_string())?;
556        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp).await?
557            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
558        let mut result = Vec::with_capacity(meta.universe.len());
559        for (asset, ctx) in meta.universe.iter().zip(ctxs.iter()) {
560            let Some(open_interest) = parse_decimal(&ctx.open_interest) else { continue };
561            let Some(funding_rate) = parse_decimal(&ctx.funding) else { continue };
562            let Some(mark_price) = parse_decimal(&ctx.mark_px) else { continue };
563            let Some(day_volume) = parse_decimal(&ctx.day_ntl_vlm) else { continue };
564            result.push(AssetContext {
565                symbol: asset.name.clone(),
566                open_interest,
567                funding_rate,
568                mark_price,
569                day_volume,
570                mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
571                oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
572                premium: ctx.premium.as_deref().and_then(parse_decimal),
573                prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
574            });
575        }
576        Ok(result)
577    }
578
579    /// Returns a full L2 orderbook snapshot for `symbol` from the l2Book REST endpoint.
580    /// Levels are returned as individual `L2Update` items; all share the same `sequence` (timestamp).
581    async fn get_l2_orderbook(&self, symbol: String) -> Result<Vec<L2Update>, String> {
582        let resp = self.client
583            .post(HYPERLIQUID_INFO_URL)
584            .json(&serde_json::json!({"type": "l2Book", "coin": symbol}))
585            .send()
586            .await
587            .map_err(|e| e.to_string())?;
588        let book: Option<WsBook> = parse_response(resp).await?;
589        let book = match book {
590            Some(b) => b,
591            None => return Ok(vec![]),
592        };
593        let mut levels = Vec::new();
594        for level in book.levels.first().into_iter().flatten() {
595            if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
596                levels.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time });
597            }
598        }
599        for level in book.levels.get(1).into_iter().flatten() {
600            if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
601                levels.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time });
602            }
603        }
604        Ok(levels)
605    }
606
607    /// Returns the mid-price of `symbol` (e.g. "BTC") from allMids.
608    async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
609        let resp = self.client
610            .post(HYPERLIQUID_INFO_URL)
611            .json(&serde_json::json!({"type": "allMids"}))
612            .send()
613            .await
614            .map_err(|e| e.to_string())?;
615        parse_response::<HashMap<String, String>>(resp).await?
616            .get(&symbol)
617            .and_then(|s| parse_decimal(s))
618            .ok_or_else(|| format!("symbol {} not found", symbol))
619    }
620
621    /// Returns predicted funding rates for all symbols across all venues.
622    /// Null venue entries (unsupported coins) are silently skipped.
623    async fn get_predicted_fundings(&self) -> Result<Vec<PredictedFunding>, String> {
624        let resp = self.client
625            .post(HYPERLIQUID_INFO_URL)
626            .json(&serde_json::json!({"type": "predictedFundings"}))
627            .send()
628            .await
629            .map_err(|e| e.to_string())?;
630        let data: PredictedFundingsResponse = parse_response(resp).await?;
631        let mut result = Vec::new();
632        for (symbol, venues) in data {
633            for (venue, entry) in venues {
634                let Some(entry) = entry else { continue };
635                if let Some(funding_rate) = parse_decimal(&entry.funding_rate) {
636                    result.push(PredictedFunding {
637                        symbol: symbol.clone(),
638                        venue,
639                        funding_rate,
640                        next_funding_time_ms: entry.next_funding_time,
641                    });
642                }
643            }
644        }
645        Ok(result)
646    }
647}
648
649#[allow(async_fn_in_trait)]
650impl guilder_abstraction::ManageOrder for HyperliquidClient {
651    /// Places an order on Hyperliquid. Requires `with_auth`. Returns an `OrderPlacement` with
652    /// the exchange-assigned order ID. Market orders are submitted as aggressive limit orders (IOC).
653    async fn place_order(&self, symbol: String, side: OrderSide, price: Decimal, volume: Decimal, order_type: OrderType, time_in_force: TimeInForce) -> Result<OrderPlacement, String> {
654        let asset_idx = self.get_asset_index(&symbol).await?;
655        let is_buy = matches!(side, OrderSide::Buy);
656
657        let tif_str = match time_in_force {
658            TimeInForce::Gtc => "Gtc",
659            TimeInForce::Ioc => "Ioc",
660            TimeInForce::Fok => "Fok",
661        };
662        // Market orders are IOC limit orders at a wide price
663        let order_type_val = match order_type {
664            OrderType::Limit => serde_json::json!({"limit": {"tif": tif_str}}),
665            OrderType::Market => serde_json::json!({"limit": {"tif": "Ioc"}}),
666        };
667
668        let action = serde_json::json!({
669            "type": "order",
670            "orders": [{
671                "a": asset_idx,
672                "b": is_buy,
673                "p": price.to_string(),
674                "s": volume.to_string(),
675                "r": false,
676                "t": order_type_val
677            }],
678            "grouping": "na"
679        });
680
681        let resp = self.submit_signed_action(action, None).await?;
682        let oid = resp["response"]["data"]["statuses"][0]["resting"]["oid"]
683            .as_i64()
684            .or_else(|| resp["response"]["data"]["statuses"][0]["filled"]["oid"].as_i64())
685            .ok_or_else(|| format!("unexpected response: {}", resp))?;
686
687        let timestamp_ms = std::time::SystemTime::now()
688            .duration_since(std::time::UNIX_EPOCH)
689            .unwrap()
690            .as_millis() as i64;
691
692        Ok(OrderPlacement { order_id: oid, symbol, side, price, quantity: volume, timestamp_ms })
693    }
694
695    /// Modifies price and size of an existing order by its order ID. Requires `with_auth`.
696    /// Fetches the order's current coin and side before submitting the modify action.
697    async fn change_order_by_cloid(&self, cloid: i64, price: Decimal, volume: Decimal) -> Result<i64, String> {
698        let user = self.require_user_address()?;
699
700        let resp = self.client
701            .post(HYPERLIQUID_INFO_URL)
702            .json(&serde_json::json!({"type": "openOrders", "user": user}))
703            .send()
704            .await
705            .map_err(|e| e.to_string())?;
706        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
707        let order = orders.iter()
708            .find(|o| o.oid == cloid)
709            .ok_or_else(|| format!("order {} not found", cloid))?;
710
711        let asset_idx = self.get_asset_index(&order.coin).await?;
712        let is_buy = order.side == "B";
713
714        let action = serde_json::json!({
715            "type": "batchModify",
716            "modifies": [{
717                "oid": cloid,
718                "order": {
719                    "a": asset_idx,
720                    "b": is_buy,
721                    "p": price.to_string(),
722                    "s": volume.to_string(),
723                    "r": false,
724                    "t": {"limit": {"tif": "Gtc"}}
725                }
726            }]
727        });
728
729        self.submit_signed_action(action, None).await?;
730        Ok(cloid)
731    }
732
733    /// Cancels a single order by its order ID. Requires `with_auth`.
734    /// Fetches open orders to resolve the coin/asset before cancelling.
735    async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
736        let user = self.require_user_address()?;
737
738        let resp = self.client
739            .post(HYPERLIQUID_INFO_URL)
740            .json(&serde_json::json!({"type": "openOrders", "user": user}))
741            .send()
742            .await
743            .map_err(|e| e.to_string())?;
744        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
745        let order = orders.iter()
746            .find(|o| o.oid == cloid)
747            .ok_or_else(|| format!("order {} not found", cloid))?;
748
749        let asset_idx = self.get_asset_index(&order.coin).await?;
750        let action = serde_json::json!({
751            "type": "cancel",
752            "cancels": [{"a": asset_idx, "o": cloid}]
753        });
754
755        self.submit_signed_action(action, None).await?;
756        Ok(cloid)
757    }
758
759    /// Cancels all open orders. Requires `with_auth`.
760    /// Fetches all open orders and submits a batch cancel in a single signed request.
761    async fn cancel_all_order(&self) -> Result<bool, String> {
762        let user = self.require_user_address()?;
763
764        let resp = self.client
765            .post(HYPERLIQUID_INFO_URL)
766            .json(&serde_json::json!({"type": "openOrders", "user": user}))
767            .send()
768            .await
769            .map_err(|e| e.to_string())?;
770        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
771        if orders.is_empty() {
772            return Ok(true);
773        }
774
775        let meta_resp = self.client
776            .post(HYPERLIQUID_INFO_URL)
777            .json(&serde_json::json!({"type": "meta"}))
778            .send()
779            .await
780            .map_err(|e| e.to_string())?;
781        let meta: MetaResponse = parse_response(meta_resp).await?;
782
783        let cancels: Vec<Value> = orders.iter()
784            .filter_map(|o| {
785                let asset_idx = meta.universe.iter().position(|a| a.name == o.coin)?;
786                Some(serde_json::json!({"a": asset_idx, "o": o.oid}))
787            })
788            .collect();
789
790        let action = serde_json::json!({"type": "cancel", "cancels": cancels});
791        self.submit_signed_action(action, None).await?;
792        Ok(true)
793    }
794}
795
796#[allow(async_fn_in_trait)]
797impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
798    fn subscribe_l2_update(&self, symbol: String) -> BoxStream<Result<L2Update, String>> {
799        let sub = serde_json::json!({
800            "method": "subscribe",
801            "subscription": {"type": "l2Book", "coin": symbol}
802        });
803        ws_subscribe(sub, |env| {
804            if env.channel != "l2Book" { return vec![]; }
805            let Ok(book) = serde_json::from_value::<WsBook>(env.data) else { return vec![]; };
806            let mut items = Vec::new();
807            for level in book.levels.first().into_iter().flatten() {
808                if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
809                    items.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time });
810                }
811            }
812            for level in book.levels.get(1).into_iter().flatten() {
813                if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
814                    items.push(L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time });
815                }
816            }
817            items
818        })
819    }
820
821    fn subscribe_asset_context(&self, symbol: String) -> BoxStream<Result<AssetContext, String>> {
822        let sub = serde_json::json!({
823            "method": "subscribe",
824            "subscription": {"type": "activeAssetCtx", "coin": symbol}
825        });
826        ws_subscribe(sub, |env| {
827            if env.channel != "activeAssetCtx" { return vec![]; }
828            let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else { return vec![]; };
829            let ctx = &update.ctx;
830            let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
831                parse_decimal(&ctx.open_interest),
832                parse_decimal(&ctx.funding),
833                parse_decimal(&ctx.mark_px),
834                parse_decimal(&ctx.day_ntl_vlm),
835            ) else { return vec![]; };
836            vec![AssetContext {
837                symbol: update.coin,
838                open_interest,
839                funding_rate,
840                mark_price,
841                day_volume,
842                mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
843                oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
844                premium: ctx.premium.as_deref().and_then(parse_decimal),
845                prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
846            }]
847        })
848    }
849
850    fn subscribe_liquidation(&self, user: String) -> BoxStream<Result<Liquidation, String>> {
851        let sub = serde_json::json!({
852            "method": "subscribe",
853            "subscription": {"type": "userEvents", "user": user}
854        });
855        ws_subscribe(sub, |env| {
856            if env.channel != "userEvents" { return vec![]; }
857            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { return vec![]; };
858            let Some(liq) = event.liquidation else { return vec![]; };
859            let (Some(notional_position), Some(account_value)) = (
860                parse_decimal(&liq.liquidated_ntl_pos),
861                parse_decimal(&liq.liquidated_account_value),
862            ) else { return vec![]; };
863            vec![Liquidation {
864                symbol: String::new(),
865                side: OrderSide::Sell,
866                liquidated_user: liq.liquidated_user,
867                notional_position,
868                account_value,
869            }]
870        })
871    }
872
873    fn subscribe_fill(&self, symbol: String) -> BoxStream<Result<Fill, String>> {
874        let sub = serde_json::json!({
875            "method": "subscribe",
876            "subscription": {"type": "trades", "coin": symbol}
877        });
878        ws_subscribe(sub, |env| {
879            if env.channel != "trades" { return vec![]; }
880            let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else { return vec![]; };
881            trades.into_iter().filter_map(|trade| {
882                let side = if trade.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
883                let price = parse_decimal(&trade.px)?;
884                let volume = parse_decimal(&trade.sz)?;
885                Some(Fill { symbol: trade.coin, price, volume, side, timestamp_ms: trade.time, trade_id: trade.tid })
886            }).collect()
887        })
888    }
889}
890
891#[allow(async_fn_in_trait)]
892impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
893    /// Returns open positions from `clearinghouseState`. Requires `with_auth`.
894    /// Zero-size positions are filtered out. Positive `szi` = long, negative = short.
895    async fn get_positions(&self) -> Result<Vec<Position>, String> {
896        let user = self.require_user_address()?;
897        let resp = self.client
898            .post(HYPERLIQUID_INFO_URL)
899            .json(&serde_json::json!({"type": "clearinghouseState", "user": user}))
900            .send()
901            .await
902            .map_err(|e| e.to_string())?;
903        let state: ClearinghouseStateResponse = parse_response(resp).await?;
904
905        Ok(state.asset_positions.into_iter()
906            .filter_map(|ap| {
907                let p = ap.position;
908                let size = parse_decimal(&p.szi)?;
909                if size.is_zero() { return None; }
910                let entry_price = p.entry_px.as_deref().and_then(parse_decimal).unwrap_or_default();
911                let side = if size > Decimal::ZERO { OrderSide::Buy } else { OrderSide::Sell };
912                Some(Position { symbol: p.coin, side, size: size.abs(), entry_price })
913            })
914            .collect())
915    }
916
917    /// Returns resting orders from Hyperliquid's `openOrders` endpoint. Requires `with_auth`.
918    /// `filled_quantity` is derived as `origSz - sz` (original size minus remaining size).
919    async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
920        let user = self.require_user_address()?;
921        let resp = self.client
922            .post(HYPERLIQUID_INFO_URL)
923            .json(&serde_json::json!({"type": "openOrders", "user": user}))
924            .send()
925            .await
926            .map_err(|e| e.to_string())?;
927        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
928
929        Ok(orders.into_iter()
930            .filter_map(|o| {
931                let price = parse_decimal(&o.limit_px)?;
932                let quantity = parse_decimal(&o.orig_sz)?;
933                let remaining = parse_decimal(&o.sz)?;
934                let filled_quantity = quantity - remaining;
935                let side = if o.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
936                Some(OpenOrder { order_id: o.oid, symbol: o.coin, side, price, quantity, filled_quantity })
937            })
938            .collect())
939    }
940
941    /// Returns total account value (collateral) from `clearinghouseState`. Requires `with_auth`.
942    async fn get_collateral(&self) -> Result<Decimal, String> {
943        let user = self.require_user_address()?;
944        let resp = self.client
945            .post(HYPERLIQUID_INFO_URL)
946            .json(&serde_json::json!({"type": "clearinghouseState", "user": user}))
947            .send()
948            .await
949            .map_err(|e| e.to_string())?;
950        let state: ClearinghouseStateResponse = parse_response(resp).await?;
951        parse_decimal(&state.margin_summary.account_value)
952            .ok_or_else(|| "invalid account value".to_string())
953    }
954}
955
956#[allow(async_fn_in_trait)]
957impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
958    fn subscribe_user_fills(&self) -> BoxStream<Result<UserFill, String>> {
959        let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
960        let sub = serde_json::json!({
961            "method": "subscribe",
962            "subscription": {"type": "userEvents", "user": format!("{:#x}", addr)}
963        });
964        ws_subscribe(sub, |env| {
965            if env.channel != "userEvents" { return vec![]; }
966            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { return vec![]; };
967            event.fills.unwrap_or_default().into_iter().filter_map(|fill| {
968                let side = if fill.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
969                let price = parse_decimal(&fill.px)?;
970                let quantity = parse_decimal(&fill.sz)?;
971                let fee_usd = parse_decimal(&fill.fee)?;
972                Some(UserFill { order_id: fill.oid, symbol: fill.coin, side, price, quantity, fee_usd, timestamp_ms: fill.time })
973            }).collect()
974        })
975    }
976
977    fn subscribe_order_updates(&self) -> BoxStream<Result<OrderUpdate, String>> {
978        let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
979        let sub = serde_json::json!({
980            "method": "subscribe",
981            "subscription": {"type": "orderUpdates", "user": format!("{:#x}", addr)}
982        });
983        ws_subscribe(sub, |env| {
984            if env.channel != "orderUpdates" { return vec![]; }
985            let Ok(updates) = serde_json::from_value::<Vec<WsOrderUpdate>>(env.data) else { return vec![]; };
986            updates.into_iter().map(|upd| {
987                let status = match upd.status.as_str() {
988                    "open" => OrderStatus::Placed,
989                    "filled" => OrderStatus::Filled,
990                    "canceled" | "cancelled" => OrderStatus::Cancelled,
991                    _ => OrderStatus::PartiallyFilled,
992                };
993                let side = if upd.order.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
994                OrderUpdate {
995                    order_id: upd.order.oid,
996                    symbol: upd.order.coin,
997                    status,
998                    side: Some(side),
999                    price: parse_decimal(&upd.order.limit_px),
1000                    quantity: parse_decimal(&upd.order.orig_sz),
1001                    remaining_quantity: parse_decimal(&upd.order.sz),
1002                    timestamp_ms: upd.status_timestamp,
1003                }
1004            }).collect()
1005        })
1006    }
1007
1008    fn subscribe_funding_payments(&self) -> BoxStream<Result<FundingPayment, String>> {
1009        let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
1010        let sub = serde_json::json!({
1011            "method": "subscribe",
1012            "subscription": {"type": "userEvents", "user": format!("{:#x}", addr)}
1013        });
1014        ws_subscribe(sub, |env| {
1015            if env.channel != "userEvents" { return vec![]; }
1016            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { return vec![]; };
1017            let Some(funding) = event.funding else { return vec![]; };
1018            let Some(amount_usd) = parse_decimal(&funding.usdc) else { return vec![]; };
1019            vec![FundingPayment { symbol: funding.coin, amount_usd, timestamp_ms: funding.time }]
1020        })
1021    }
1022
1023    fn subscribe_deposits(&self) -> BoxStream<Result<Deposit, String>> {
1024        let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
1025        let sub = serde_json::json!({
1026            "method": "subscribe",
1027            "subscription": {"type": "userNonFundingLedgerUpdates", "user": format!("{:#x}", addr)}
1028        });
1029        ws_subscribe(sub, |env| {
1030            if env.channel != "userNonFundingLedgerUpdates" { return vec![]; }
1031            let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else { return vec![]; };
1032            ledger.updates.into_iter().filter_map(|e| {
1033                if e.delta.kind != "deposit" { return None; }
1034                let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1035                Some(Deposit { asset: "USDC".to_string(), amount_usd, timestamp_ms: e.time })
1036            }).collect()
1037        })
1038    }
1039
1040    fn subscribe_withdrawals(&self) -> BoxStream<Result<Withdrawal, String>> {
1041        let Some(addr) = self.user_address else { return Box::pin(stream::empty()); };
1042        let sub = serde_json::json!({
1043            "method": "subscribe",
1044            "subscription": {"type": "userNonFundingLedgerUpdates", "user": format!("{:#x}", addr)}
1045        });
1046        ws_subscribe(sub, |env| {
1047            if env.channel != "userNonFundingLedgerUpdates" { return vec![]; }
1048            let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else { return vec![]; };
1049            ledger.updates.into_iter().filter_map(|e| {
1050                if e.delta.kind != "withdraw" { return None; }
1051                let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1052                Some(Withdrawal { asset: "USDC".to_string(), amount_usd, timestamp_ms: e.time })
1053            }).collect()
1054        })
1055    }
1056}