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