Skip to main content

guilder_client_hyperliquid/
client.rs

1use crate::rate_limiter::{RestRateLimiter, AddressRateLimiter};
2use alloy_primitives::Address;
3use futures_util::{stream, StreamExt};
4use guilder_abstraction::{
5    self, AssetContext, BoxStream, Deposit, Fill, FundingPayment, L2Update, Liquidation, OpenOrder,
6    OrderPlacement, OrderSide, OrderStatus, OrderType, OrderUpdate, Position, PredictedFunding,
7    Side, TimeInForce, UserFill, Withdrawal,
8};
9use reqwest::Client;
10use rust_decimal::Decimal;
11use serde::Deserialize;
12use serde_json::Value;
13use std::collections::HashMap;
14use std::str::FromStr;
15use std::sync::Arc;
16const HYPERLIQUID_INFO_URL: &str = "https://api.hyperliquid.xyz/info";
17const HYPERLIQUID_EXCHANGE_URL: &str = "https://api.hyperliquid.xyz/exchange";
18
19async fn parse_response<T: for<'de> serde::Deserialize<'de>>(
20    resp: reqwest::Response,
21) -> Result<T, String> {
22    let text = resp.text().await.map_err(|e| e.to_string())?;
23    serde_json::from_str(&text).map_err(|e| format!("{e}: {text}"))
24}
25
26pub struct HyperliquidClient {
27    client: Client,
28    user_address: Option<Address>,
29    private_key: Option<String>,
30    rest_limiter: Arc<RestRateLimiter>,
31    address_limiter: Arc<AddressRateLimiter>,
32    ws_mux: crate::ws::WsMux,
33}
34
35impl Default for HyperliquidClient {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl HyperliquidClient {
42    pub fn new() -> Self {
43        HyperliquidClient {
44            client: Client::new(),
45            user_address: None,
46            private_key: None,
47            rest_limiter: Arc::new(RestRateLimiter::new()),
48            address_limiter: Arc::new(AddressRateLimiter::new()),
49            ws_mux: crate::ws::WsMux::new(),
50        }
51    }
52
53    pub fn with_auth(user_address: Address, private_key: String) -> Self {
54        HyperliquidClient {
55            client: Client::new(),
56            user_address: Some(user_address),
57            private_key: Some(private_key),
58            rest_limiter: Arc::new(RestRateLimiter::new()),
59            address_limiter: Arc::new(AddressRateLimiter::new()),
60            ws_mux: crate::ws::WsMux::new(),
61        }
62    }
63
64    /// Configure rate limit budgets (rest_weight/min, address_requests).
65    /// Defaults: 1200 rest weight/min, 10000 address requests.
66    pub fn with_budgets(mut self, rest_weight: u32, addr_budget: u64) -> Self {
67        self.rest_limiter = Arc::new(RestRateLimiter::new_with_budget(rest_weight));
68        self.address_limiter = Arc::new(AddressRateLimiter::new_with_budget(addr_budget));
69        self
70    }
71
72    /// POST to the info endpoint, consuming `weight` from the REST rate-limit budget.
73    async fn info_post(&self, body: Value, weight: u32, call: &str) -> Result<reqwest::Response, String> {
74        self.rest_limiter.acquire_blocking(weight, call).await;
75        self.client
76            .post(HYPERLIQUID_INFO_URL)
77            .json(&body)
78            .send()
79            .await
80            .map_err(|e| e.to_string())
81    }
82
83    /// POST to the exchange endpoint, consuming `weight` from the REST rate-limit budget.
84    /// Weight = 1 + floor(batch_length / 40).
85    async fn exchange_post(&self, body: Value, weight: u32, call: &str) -> Result<reqwest::Response, String> {
86        self.rest_limiter.acquire_blocking(weight, call).await;
87        self.client
88            .post(HYPERLIQUID_EXCHANGE_URL)
89            .json(&body)
90            .send()
91            .await
92            .map_err(|e| e.to_string())
93    }
94
95    fn require_user_address(&self) -> Result<String, String> {
96        self.user_address
97            .map(|a| format!("{:#x}", a))
98            .ok_or_else(|| "user address required: use HyperliquidClient::with_auth".to_string())
99    }
100
101    fn require_private_key(&self) -> Result<&str, String> {
102        self.private_key
103            .as_deref()
104            .ok_or_else(|| "private key required: use HyperliquidClient::with_auth".to_string())
105    }
106
107    async fn get_asset_index(&self, symbol: &str) -> Result<usize, String> {
108        // `meta` is an "all other info" request → weight 20
109        let resp = self
110            .info_post(serde_json::json!({"type": "meta"}), 20, "get_asset_index")
111            .await?;
112        let meta: MetaResponse = parse_response(resp).await?;
113        meta.universe
114            .iter()
115            .position(|a| a.name == symbol)
116            .ok_or_else(|| format!("symbol {} not found", symbol))
117    }
118
119    async fn submit_signed_action(
120        &self,
121        action: Value,
122        vault_address: Option<&str>,
123    ) -> Result<Value, String> {
124        let private_key = self.require_private_key()?;
125        let nonce = std::time::SystemTime::now()
126            .duration_since(std::time::UNIX_EPOCH)
127            .unwrap()
128            .as_millis() as u64;
129
130        let (r, s, v) = sign_action(private_key, &action, vault_address, nonce)?;
131
132        let payload = serde_json::json!({
133            "action": action,
134            "nonce": nonce,
135            "signature": {"r": r, "s": s, "v": v},
136            "vaultAddress": null
137        });
138
139        // Single unbatched action → exchange weight 1
140        let resp = self.exchange_post(payload, 1, "submit_signed_action").await?;
141
142        let body: Value = parse_response(resp).await?;
143        if body["status"].as_str() == Some("err") {
144            return Err(body["response"]
145                .as_str()
146                .unwrap_or("unknown error")
147                .to_string());
148        }
149        Ok(body)
150    }
151}
152
153// --- REST deserialization types ---
154
155#[derive(Deserialize)]
156struct MetaResponse {
157    universe: Vec<AssetInfo>,
158}
159
160#[derive(Deserialize)]
161struct AssetInfo {
162    name: String,
163}
164
165type MetaAndAssetCtxsResponse = (MetaResponse, Vec<RestAssetCtx>);
166
167#[derive(Deserialize)]
168#[serde(rename_all = "camelCase")]
169#[allow(dead_code)]
170struct RestAssetCtx {
171    open_interest: String,
172    funding: String,
173    mark_px: String,
174    day_ntl_vlm: String,
175    mid_px: Option<String>,
176    oracle_px: Option<String>,
177    premium: Option<String>,
178    prev_day_px: Option<String>,
179}
180
181#[derive(Deserialize)]
182#[serde(rename_all = "camelCase")]
183struct ClearinghouseStateResponse {
184    margin_summary: MarginSummary,
185    asset_positions: Vec<AssetPosition>,
186}
187
188#[derive(Deserialize)]
189#[serde(rename_all = "camelCase")]
190struct MarginSummary {
191    account_value: String,
192}
193
194#[derive(Deserialize)]
195struct AssetPosition {
196    position: PositionDetail,
197}
198
199#[derive(Deserialize)]
200#[serde(rename_all = "camelCase")]
201struct PositionDetail {
202    coin: String,
203    /// positive = long, negative = short
204    szi: String,
205    entry_px: Option<String>,
206}
207
208#[derive(Deserialize)]
209#[serde(rename_all = "camelCase")]
210struct RestOpenOrder {
211    coin: String,
212    side: String,
213    limit_px: String,
214    sz: String,
215    oid: i64,
216    orig_sz: String,
217}
218
219// predictedFundings response: Vec<(coin, Vec<(venue, entry_or_null)>)>
220// The API returns null for venues that don't list the coin.
221type PredictedFundingsResponse = Vec<(String, Vec<(String, Option<PredictedFundingEntry>)>)>;
222
223#[derive(Deserialize)]
224#[serde(rename_all = "camelCase")]
225struct PredictedFundingEntry {
226    funding_rate: String,
227    next_funding_time: i64,
228}
229
230// --- WebSocket envelope and data shapes ---
231
232#[derive(Deserialize)]
233struct WsEnvelope {
234    channel: String,
235    #[serde(default)]
236    data: Value,
237}
238
239#[derive(Deserialize)]
240struct WsBook {
241    coin: String,
242    levels: Vec<Vec<WsLevel>>,
243    time: i64,
244}
245
246#[derive(Deserialize)]
247struct WsLevel {
248    px: String,
249    sz: String,
250}
251
252#[derive(Deserialize)]
253#[serde(rename_all = "camelCase")]
254struct WsAssetCtx {
255    coin: String,
256    ctx: WsPerpsCtx,
257}
258
259#[derive(Deserialize)]
260#[serde(rename_all = "camelCase")]
261struct WsPerpsCtx {
262    open_interest: String,
263    funding: String,
264    mark_px: String,
265    day_ntl_vlm: String,
266    mid_px: Option<String>,
267    oracle_px: Option<String>,
268    premium: Option<String>,
269    prev_day_px: Option<String>,
270}
271
272#[derive(Deserialize)]
273struct WsUserEvent {
274    liquidation: Option<WsLiquidation>,
275    fills: Option<Vec<WsUserFill>>,
276    funding: Option<WsFunding>,
277}
278
279#[derive(Deserialize)]
280struct WsLiquidation {
281    liquidated_user: String,
282    liquidated_ntl_pos: String,
283    liquidated_account_value: String,
284}
285
286#[derive(Deserialize)]
287struct WsUserFill {
288    coin: String,
289    px: String,
290    sz: String,
291    side: String,
292    time: i64,
293    oid: i64,
294    fee: String,
295    /// Client order ID assigned at placement, if any.
296    #[serde(default)]
297    cloid: Option<String>,
298}
299
300#[derive(Deserialize)]
301struct WsFunding {
302    time: i64,
303    coin: String,
304    usdc: String,
305}
306
307#[derive(Deserialize)]
308struct WsTrade {
309    coin: String,
310    side: String,
311    px: String,
312    sz: String,
313    time: i64,
314    tid: i64,
315}
316
317#[derive(Deserialize)]
318struct WsOrderUpdate {
319    order: WsOrderInfo,
320    status: String,
321    #[serde(rename = "statusTimestamp")]
322    status_timestamp: i64,
323}
324
325#[derive(Deserialize)]
326#[serde(rename_all = "camelCase")]
327struct WsOrderInfo {
328    coin: String,
329    side: String,
330    limit_px: String,
331    sz: String,
332    oid: i64,
333    orig_sz: String,
334    /// Client order ID assigned at placement, if any.
335    #[serde(default)]
336    cloid: Option<String>,
337}
338
339// --- WebSocket ledger update shapes (deposits / withdrawals) ---
340
341#[derive(Deserialize)]
342struct WsLedgerUpdates {
343    updates: Vec<WsLedgerEntry>,
344}
345
346#[derive(Deserialize)]
347struct WsLedgerEntry {
348    time: i64,
349    delta: WsLedgerDelta,
350}
351
352#[derive(Deserialize)]
353struct WsLedgerDelta {
354    #[serde(rename = "type")]
355    kind: String,
356    usdc: Option<String>,
357}
358
359// --- Helpers ---
360
361fn parse_decimal(s: &str) -> Option<Decimal> {
362    Decimal::from_str(s).ok()
363}
364
365fn keccak256(data: &[u8]) -> [u8; 32] {
366    use sha3::{Digest, Keccak256};
367    Keccak256::digest(data).into()
368}
369
370/// EIP-712 domain separator for Hyperliquid mainnet (Arbitrum, chainId=42161).
371fn hyperliquid_domain_separator() -> [u8; 32] {
372    let type_hash = keccak256(
373        b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
374    );
375    let name_hash = keccak256(b"Exchange");
376    let version_hash = keccak256(b"1");
377    let mut chain_id = [0u8; 32];
378    chain_id[28..32].copy_from_slice(&42161u32.to_be_bytes());
379    let verifying_contract = [0u8; 32];
380
381    let mut data = [0u8; 160];
382    data[..32].copy_from_slice(&type_hash);
383    data[32..64].copy_from_slice(&name_hash);
384    data[64..96].copy_from_slice(&version_hash);
385    data[96..128].copy_from_slice(&chain_id);
386    data[128..160].copy_from_slice(&verifying_contract);
387    keccak256(&data)
388}
389
390/// Signs a Hyperliquid exchange action using EIP-712.
391/// Returns (r, s, v) where r and s are "0x"-prefixed hex strings and v is 27 or 28.
392fn sign_action(
393    private_key: &str,
394    action: &Value,
395    vault_address: Option<&str>,
396    nonce: u64,
397) -> Result<(String, String, u8), String> {
398    use k256::ecdsa::SigningKey;
399
400    // Step 1: msgpack-encode the action, append nonce + vault flag
401    let msgpack_bytes = rmp_serde::to_vec(action).map_err(|e| e.to_string())?;
402    let mut data = msgpack_bytes;
403    data.extend_from_slice(&nonce.to_be_bytes());
404    match vault_address {
405        None => data.push(0u8),
406        Some(addr) => {
407            data.push(1u8);
408            let addr_bytes = hex::decode(addr.trim_start_matches("0x"))
409                .map_err(|e| format!("invalid vault address: {}", e))?;
410            data.extend_from_slice(&addr_bytes);
411        }
412    }
413    let connection_id = keccak256(&data);
414
415    // Step 2: hash the Agent struct
416    let agent_type_hash = keccak256(b"Agent(string source,bytes32 connectionId)");
417    let source_hash = keccak256(b"a"); // "a" = mainnet
418    let mut struct_data = [0u8; 96];
419    struct_data[..32].copy_from_slice(&agent_type_hash);
420    struct_data[32..64].copy_from_slice(&source_hash);
421    struct_data[64..96].copy_from_slice(&connection_id);
422    let struct_hash = keccak256(&struct_data);
423
424    // Step 3: EIP-712 final hash
425    let domain_sep = hyperliquid_domain_separator();
426    let mut final_data = Vec::with_capacity(66);
427    final_data.extend_from_slice(b"\x19\x01");
428    final_data.extend_from_slice(&domain_sep);
429    final_data.extend_from_slice(&struct_hash);
430    let final_hash = keccak256(&final_data);
431
432    // Step 4: sign with secp256k1
433    let key_bytes = hex::decode(private_key.trim_start_matches("0x"))
434        .map_err(|e| format!("invalid private key: {}", e))?;
435    let signing_key =
436        SigningKey::from_bytes(key_bytes.as_slice().into()).map_err(|e| e.to_string())?;
437    let (sig, recovery_id) = signing_key
438        .sign_prehash_recoverable(&final_hash)
439        .map_err(|e| e.to_string())?;
440
441    let sig_bytes = sig.to_bytes();
442    let r = format!("0x{}", hex::encode(&sig_bytes[..32]));
443    let s = format!("0x{}", hex::encode(&sig_bytes[32..64]));
444    let v = 27u8 + recovery_id.to_byte();
445
446    Ok((r, s, v))
447}
448
449// --- Trait implementations ---
450
451#[allow(async_fn_in_trait)]
452impl guilder_abstraction::TestServer for HyperliquidClient {
453    /// Sends a lightweight allMids request; returns true if the server responds 200 OK.
454    async fn ping(&self) -> Result<bool, String> {
455        // allMids → weight 2
456        self.info_post(serde_json::json!({"type": "allMids"}), 2, "ping")
457            .await
458            .map(|r| r.status().is_success())
459    }
460
461    /// Hyperliquid has no dedicated server-time endpoint; returns local UTC ms.
462    async fn get_server_time(&self) -> Result<i64, String> {
463        Ok(std::time::SystemTime::now()
464            .duration_since(std::time::UNIX_EPOCH)
465            .map(|d| d.as_millis() as i64)
466            .unwrap_or(0))
467    }
468}
469
470#[allow(async_fn_in_trait)]
471impl guilder_abstraction::GetMarketData for HyperliquidClient {
472    /// Returns all perpetual asset names from Hyperliquid's meta endpoint.
473    async fn get_symbol(&self) -> Result<Vec<String>, String> {
474        // meta → weight 20
475        let resp = self
476            .info_post(serde_json::json!({"type": "meta"}), 20, "get_symbol")
477            .await?;
478        parse_response::<MetaResponse>(resp)
479            .await
480            .map(|r| r.universe.into_iter().map(|a| a.name).collect())
481    }
482
483    /// Returns the current open interest for `symbol` from metaAndAssetCtxs.
484    async fn get_open_interest(&self, symbol: String) -> Result<Decimal, String> {
485        // metaAndAssetCtxs → weight 20
486        let resp = self
487            .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20, "get_open_interest")
488            .await?;
489        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
490            .await?
491            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
492        meta.universe
493            .iter()
494            .position(|a| a.name == symbol)
495            .and_then(|i| ctxs.get(i))
496            .and_then(|ctx| parse_decimal(&ctx.open_interest))
497            .ok_or_else(|| format!("symbol {} not found", symbol))
498    }
499
500    /// Returns a full AssetContext snapshot for `symbol` from metaAndAssetCtxs.
501    async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
502        // metaAndAssetCtxs → weight 20
503        let resp = self
504            .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20, "get_asset_context")
505            .await?;
506        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
507            .await?
508            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
509        let idx = meta
510            .universe
511            .iter()
512            .position(|a| a.name == symbol)
513            .ok_or_else(|| format!("symbol {} not found", symbol))?;
514        let ctx = ctxs
515            .get(idx)
516            .ok_or_else(|| format!("symbol {} not found", symbol))?;
517        Ok(AssetContext {
518            symbol,
519            open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
520            funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
521            mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
522            day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
523            mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
524            oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
525            premium: ctx.premium.as_deref().and_then(parse_decimal),
526            prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
527        })
528    }
529
530    /// Fetches metaAndAssetCtxs once and returns all asset contexts in universe order.
531    /// Prefer this over repeated `get_asset_context` calls to avoid rate-limiting.
532    async fn get_all_asset_contexts(&self) -> Result<Vec<AssetContext>, String> {
533        // metaAndAssetCtxs → weight 20
534        let resp = self
535            .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20, "get_all_asset_contexts")
536            .await?;
537        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
538            .await?
539            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
540        let mut result = Vec::with_capacity(meta.universe.len());
541        for (asset, ctx) in meta.universe.iter().zip(ctxs.iter()) {
542            let Some(open_interest) = parse_decimal(&ctx.open_interest) else {
543                continue;
544            };
545            let Some(funding_rate) = parse_decimal(&ctx.funding) else {
546                continue;
547            };
548            let Some(mark_price) = parse_decimal(&ctx.mark_px) else {
549                continue;
550            };
551            let Some(day_volume) = parse_decimal(&ctx.day_ntl_vlm) else {
552                continue;
553            };
554            result.push(AssetContext {
555                symbol: asset.name.clone(),
556                open_interest,
557                funding_rate,
558                mark_price,
559                day_volume,
560                mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
561                oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
562                premium: ctx.premium.as_deref().and_then(parse_decimal),
563                prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
564            });
565        }
566        Ok(result)
567    }
568
569    /// Returns a full L2 orderbook snapshot for `symbol` from the l2Book REST endpoint.
570    /// Levels are returned as individual `L2Update` items; all share the same `sequence` (timestamp).
571    async fn get_l2_orderbook(&self, symbol: String) -> Result<Vec<L2Update>, String> {
572        // l2Book → weight 2
573        let resp = self
574            .info_post(serde_json::json!({"type": "l2Book", "coin": symbol}), 2, "get_l2_orderbook")
575            .await?;
576        let book: Option<WsBook> = parse_response(resp).await?;
577        let book = match book {
578            Some(b) => b,
579            None => return Ok(vec![]),
580        };
581        let mut levels = Vec::new();
582        for level in book.levels.first().into_iter().flatten() {
583            if let (Some(price), Some(volume)) =
584                (parse_decimal(&level.px), parse_decimal(&level.sz))
585            {
586                levels.push(L2Update {
587                    symbol: book.coin.clone(),
588                    price,
589                    volume,
590                    side: Side::Ask,
591                    sequence: book.time,
592                });
593            }
594        }
595        for level in book.levels.get(1).into_iter().flatten() {
596            if let (Some(price), Some(volume)) =
597                (parse_decimal(&level.px), parse_decimal(&level.sz))
598            {
599                levels.push(L2Update {
600                    symbol: book.coin.clone(),
601                    price,
602                    volume,
603                    side: Side::Bid,
604                    sequence: book.time,
605                });
606            }
607        }
608        Ok(levels)
609    }
610
611    /// Returns the mid-price of `symbol` (e.g. "BTC") from allMids.
612    async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
613        // allMids → weight 2
614        let resp = self
615            .info_post(serde_json::json!({"type": "allMids"}), 2, "get_price")
616            .await?;
617        parse_response::<HashMap<String, String>>(resp)
618            .await?
619            .get(&symbol)
620            .and_then(|s| parse_decimal(s))
621            .ok_or_else(|| format!("symbol {} not found", symbol))
622    }
623
624    /// Returns predicted funding rates for all symbols across all venues.
625    /// Null venue entries (unsupported coins) are silently skipped.
626    async fn get_predicted_fundings(&self) -> Result<Vec<PredictedFunding>, String> {
627        // predictedFundings → weight 20
628        let resp = self
629            .info_post(serde_json::json!({"type": "predictedFundings"}), 20, "get_predicted_fundings")
630            .await?;
631        let data: PredictedFundingsResponse = parse_response(resp).await?;
632        let mut result = Vec::new();
633        for (symbol, venues) in data {
634            for (venue, entry) in venues {
635                let Some(entry) = entry else { continue };
636                if let Some(funding_rate) = parse_decimal(&entry.funding_rate) {
637                    result.push(PredictedFunding {
638                        symbol: symbol.clone(),
639                        venue,
640                        funding_rate,
641                        next_funding_time_ms: entry.next_funding_time,
642                    });
643                }
644            }
645        }
646        Ok(result)
647    }
648}
649
650#[allow(async_fn_in_trait)]
651impl guilder_abstraction::ManageOrder for HyperliquidClient {
652    /// Places an order on Hyperliquid. Requires `with_auth`. Returns an `OrderPlacement` with
653    /// the exchange-assigned order ID. Market orders are submitted as aggressive limit orders (IOC).
654    ///
655    /// If `cloid` is provided, Hyperliquid attaches it to the order lifecycle — fills and order
656    /// updates will carry the same cloid back, enabling end-to-end intent tracing without a
657    /// separate order_id mapping.
658    async fn place_order(
659        &self,
660        symbol: String,
661        side: OrderSide,
662        price: Decimal,
663        volume: Decimal,
664        order_type: OrderType,
665        time_in_force: TimeInForce,
666        cloid: Option<String>,
667    ) -> Result<OrderPlacement, String> {
668        let asset_idx = self.get_asset_index(&symbol).await?;
669        let is_buy = matches!(side, OrderSide::Buy);
670
671        let tif_str = match time_in_force {
672            TimeInForce::Gtc => "Gtc",
673            TimeInForce::Ioc => "Ioc",
674            TimeInForce::Fok => "Fok",
675        };
676        // Market orders are IOC limit orders at a wide price
677        let order_type_val = match order_type {
678            OrderType::Limit => serde_json::json!({"limit": {"tif": tif_str}}),
679            OrderType::Market => serde_json::json!({"limit": {"tif": "Ioc"}}),
680        };
681
682        let mut order_json = serde_json::json!({
683            "a": asset_idx,
684            "b": is_buy,
685            "p": price.to_string(),
686            "s": volume.to_string(),
687            "r": false,
688            "t": order_type_val
689        });
690        if let Some(ref c) = cloid {
691            order_json["c"] = serde_json::json!(c);
692        }
693
694        let action = serde_json::json!({
695            "type": "order",
696            "orders": [order_json],
697            "grouping": "na"
698        });
699
700        let resp = self.submit_signed_action(action, None).await?;
701        let oid = resp["response"]["data"]["statuses"][0]["resting"]["oid"]
702            .as_i64()
703            .or_else(|| resp["response"]["data"]["statuses"][0]["filled"]["oid"].as_i64())
704            .ok_or_else(|| format!("unexpected response: {}", resp))?;
705
706        let timestamp_ms = std::time::SystemTime::now()
707            .duration_since(std::time::UNIX_EPOCH)
708            .unwrap()
709            .as_millis() as i64;
710
711        Ok(OrderPlacement {
712            order_id: oid,
713            symbol,
714            side,
715            price,
716            quantity: volume,
717            timestamp_ms,
718            cloid,
719        })
720    }
721
722    /// Modifies price and size of an existing order by its order ID. Requires `with_auth`.
723    /// Fetches the order's current coin and side before submitting the modify action.
724    async fn change_order_by_cloid(
725        &self,
726        cloid: i64,
727        price: Decimal,
728        volume: Decimal,
729    ) -> Result<i64, String> {
730        let user = self.require_user_address()?;
731
732        // openOrders → weight 20; get_asset_index → meta weight 20
733        let resp = self
734            .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20, "change_order_by_cloid")
735            .await?;
736        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
737        let order = orders
738            .iter()
739            .find(|o| o.oid == cloid)
740            .ok_or_else(|| format!("order {} not found", cloid))?;
741
742        let asset_idx = self.get_asset_index(&order.coin).await?;
743        let is_buy = order.side == "B";
744
745        let action = serde_json::json!({
746            "type": "batchModify",
747            "modifies": [{
748                "oid": cloid,
749                "order": {
750                    "a": asset_idx,
751                    "b": is_buy,
752                    "p": price.to_string(),
753                    "s": volume.to_string(),
754                    "r": false,
755                    "t": {"limit": {"tif": "Gtc"}}
756                }
757            }]
758        });
759
760        self.submit_signed_action(action, None).await?;
761        Ok(cloid)
762    }
763
764    /// Cancels a single order by its order ID. Requires `with_auth`.
765    /// Fetches open orders to resolve the coin/asset before cancelling.
766    async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
767        let user = self.require_user_address()?;
768
769        // openOrders → weight 20
770        let resp = self
771            .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20, "cancel_order")
772            .await?;
773        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
774        let order = orders
775            .iter()
776            .find(|o| o.oid == cloid)
777            .ok_or_else(|| format!("order {} not found", cloid))?;
778
779        let asset_idx = self.get_asset_index(&order.coin).await?;
780        let action = serde_json::json!({
781            "type": "cancel",
782            "cancels": [{"a": asset_idx, "o": cloid}]
783        });
784
785        self.submit_signed_action(action, None).await?;
786        Ok(cloid)
787    }
788
789    /// Cancels all open orders. Requires `with_auth`.
790    /// Fetches all open orders and submits a batch cancel in a single signed request.
791    async fn cancel_all_order(&self) -> Result<bool, String> {
792        let user = self.require_user_address()?;
793
794        // openOrders → weight 20
795        let resp = self
796            .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20, "cancel_all_order")
797            .await?;
798        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
799        if orders.is_empty() {
800            return Ok(true);
801        }
802
803        // meta → weight 20
804        let meta_resp = self
805            .info_post(serde_json::json!({"type": "meta"}), 20, "cancel_all_order")
806            .await?;
807        let meta: MetaResponse = parse_response(meta_resp).await?;
808
809        let cancels: Vec<Value> = orders
810            .iter()
811            .filter_map(|o| {
812                let asset_idx = meta.universe.iter().position(|a| a.name == o.coin)?;
813                Some(serde_json::json!({"a": asset_idx, "o": o.oid}))
814            })
815            .collect();
816
817        let action = serde_json::json!({"type": "cancel", "cancels": cancels});
818        self.submit_signed_action(action, None).await?;
819        Ok(true)
820    }
821}
822
823#[allow(async_fn_in_trait)]
824impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
825    fn subscribe_l2_update(&self, symbol: String) -> BoxStream<Result<L2Update, String>> {
826        let sub = serde_json::json!({
827            "method": "subscribe",
828            "subscription": {"type": "l2Book", "coin": symbol.clone()}
829        });
830        let key = crate::ws::SubKey {
831            channel: "l2Book".to_string(),
832            routing_key: symbol,
833        };
834        let stream = self.ws_mux.subscribe(key, sub);
835        Box::pin(async_stream::stream! {
836            for await msg in stream {
837                let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
838                    continue;
839                };
840                if env.channel != "l2Book" {
841                    continue;
842                }
843                let Ok(book) = serde_json::from_value::<WsBook>(env.data) else {
844                    continue;
845                };
846                for level in book.levels.first().into_iter().flatten() {
847                    if let (Some(price), Some(volume)) =
848                        (parse_decimal(&level.px), parse_decimal(&level.sz))
849                    {
850                        yield Ok(L2Update {
851                            symbol: book.coin.clone(),
852                            price,
853                            volume,
854                            side: Side::Ask,
855                            sequence: book.time,
856                        });
857                    }
858                }
859                for level in book.levels.get(1).into_iter().flatten() {
860                    if let (Some(price), Some(volume)) =
861                        (parse_decimal(&level.px), parse_decimal(&level.sz))
862                    {
863                        yield Ok(L2Update {
864                            symbol: book.coin.clone(),
865                            price,
866                            volume,
867                            side: Side::Bid,
868                            sequence: book.time,
869                        });
870                    }
871                }
872            }
873        })
874    }
875
876    fn subscribe_asset_context(&self, symbol: String) -> BoxStream<Result<AssetContext, String>> {
877        let sub = serde_json::json!({
878            "method": "subscribe",
879            "subscription": {"type": "activeAssetCtx", "coin": symbol.clone()}
880        });
881        let key = crate::ws::SubKey {
882            channel: "activeAssetCtx".to_string(),
883            routing_key: symbol,
884        };
885        let stream = self.ws_mux.subscribe(key, sub);
886        Box::pin(async_stream::stream! {
887            for await msg in stream {
888                let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
889                    continue;
890                };
891                if env.channel != "activeAssetCtx" {
892                    continue;
893                }
894                let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else {
895                    continue;
896                };
897                let ctx = &update.ctx;
898                let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
899                    parse_decimal(&ctx.open_interest),
900                    parse_decimal(&ctx.funding),
901                    parse_decimal(&ctx.mark_px),
902                    parse_decimal(&ctx.day_ntl_vlm),
903                ) else {
904                    continue;
905                };
906                yield Ok(AssetContext {
907                    symbol: update.coin,
908                    open_interest,
909                    funding_rate,
910                    mark_price,
911                    day_volume,
912                    mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
913                    oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
914                    premium: ctx.premium.as_deref().and_then(parse_decimal),
915                    prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
916                });
917            }
918        })
919    }
920
921    fn subscribe_liquidation(&self, user: String) -> BoxStream<Result<Liquidation, String>> {
922        let sub = serde_json::json!({
923            "method": "subscribe",
924            "subscription": {"type": "userEvents", "user": user.clone()}
925        });
926        let key = crate::ws::SubKey {
927            channel: "userEvents".to_string(),
928            routing_key: user,
929        };
930        let raw_stream = self.ws_mux.subscribe(key, sub);
931        Box::pin(raw_stream.filter_map(|text| async move {
932            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
933                return None;
934            };
935            if env.channel != "userEvents" {
936                return None;
937            }
938            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
939                return None;
940            };
941            let Some(liq) = event.liquidation else {
942                return None;
943            };
944            let (Some(notional_position), Some(account_value)) = (
945                parse_decimal(&liq.liquidated_ntl_pos),
946                parse_decimal(&liq.liquidated_account_value),
947            ) else {
948                return None;
949            };
950            let item = Liquidation {
951                symbol: String::new(),
952                side: OrderSide::Sell,
953                liquidated_user: liq.liquidated_user,
954                notional_position,
955                account_value,
956            };
957            Some(stream::iter(vec![Ok(item)].into_iter()))
958        }).flatten())
959    }
960
961    fn subscribe_fill(&self, symbol: String) -> BoxStream<Result<Fill, String>> {
962        let sub = serde_json::json!({
963            "method": "subscribe",
964            "subscription": {"type": "trades", "coin": symbol.clone()}
965        });
966        let key = crate::ws::SubKey {
967            channel: "trades".to_string(),
968            routing_key: symbol,
969        };
970        let stream = self.ws_mux.subscribe(key, sub);
971        Box::pin(async_stream::stream! {
972            for await msg in stream {
973                let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
974                    continue;
975                };
976                if env.channel != "trades" {
977                    continue;
978                }
979                let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else {
980                    continue;
981                };
982                for trade in trades {
983                    let side = if trade.side == "B" {
984                        OrderSide::Buy
985                    } else {
986                        OrderSide::Sell
987                    };
988                    let price = parse_decimal(&trade.px);
989                    let volume = parse_decimal(&trade.sz);
990                    if let (Some(price), Some(volume)) = (price, volume) {
991                        yield Ok(Fill {
992                            symbol: trade.coin,
993                            price,
994                            volume,
995                            side,
996                            timestamp_ms: trade.time,
997                            trade_id: trade.tid,
998                        });
999                    }
1000                }
1001            }
1002        })
1003    }
1004}
1005
1006#[allow(async_fn_in_trait)]
1007impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
1008    /// Returns open positions from `clearinghouseState`. Requires `with_auth`.
1009    /// Zero-size positions are filtered out. Positive `szi` = long, negative = short.
1010    async fn get_positions(&self) -> Result<Vec<Position>, String> {
1011        let user = self.require_user_address()?;
1012        // clearinghouseState → weight 2
1013        let resp = self
1014            .info_post(
1015                serde_json::json!({"type": "clearinghouseState", "user": user}),
1016                2,
1017                "get_positions",
1018            )
1019            .await?;
1020        let state: ClearinghouseStateResponse = parse_response(resp).await?;
1021
1022        Ok(state
1023            .asset_positions
1024            .into_iter()
1025            .filter_map(|ap| {
1026                let p = ap.position;
1027                let size = parse_decimal(&p.szi)?;
1028                if size.is_zero() {
1029                    return None;
1030                }
1031                let entry_price = p
1032                    .entry_px
1033                    .as_deref()
1034                    .and_then(parse_decimal)
1035                    .unwrap_or_default();
1036                let side = if size > Decimal::ZERO {
1037                    OrderSide::Buy
1038                } else {
1039                    OrderSide::Sell
1040                };
1041                Some(Position {
1042                    symbol: p.coin,
1043                    side,
1044                    size: size.abs(),
1045                    entry_price,
1046                })
1047            })
1048            .collect())
1049    }
1050
1051    /// Returns resting orders from Hyperliquid's `openOrders` endpoint. Requires `with_auth`.
1052    /// `filled_quantity` is derived as `origSz - sz` (original size minus remaining size).
1053    async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
1054        let user = self.require_user_address()?;
1055        // openOrders → weight 20
1056        let resp = self
1057            .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20, "get_open_orders")
1058            .await?;
1059        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
1060
1061        Ok(orders
1062            .into_iter()
1063            .filter_map(|o| {
1064                let price = parse_decimal(&o.limit_px)?;
1065                let quantity = parse_decimal(&o.orig_sz)?;
1066                let remaining = parse_decimal(&o.sz)?;
1067                let filled_quantity = quantity - remaining;
1068                let side = if o.side == "B" {
1069                    OrderSide::Buy
1070                } else {
1071                    OrderSide::Sell
1072                };
1073                Some(OpenOrder {
1074                    order_id: o.oid,
1075                    symbol: o.coin,
1076                    side,
1077                    price,
1078                    quantity,
1079                    filled_quantity,
1080                })
1081            })
1082            .collect())
1083    }
1084
1085    /// Returns total account value (collateral) from `clearinghouseState`. Requires `with_auth`.
1086    async fn get_collateral(&self) -> Result<Decimal, String> {
1087        let user = self.require_user_address()?;
1088        // clearinghouseState → weight 2
1089        let resp = self
1090            .info_post(
1091                serde_json::json!({"type": "clearinghouseState", "user": user}),
1092                2,
1093                "get_collateral",
1094            )
1095            .await?;
1096        let state: ClearinghouseStateResponse = parse_response(resp).await?;
1097        parse_decimal(&state.margin_summary.account_value)
1098            .ok_or_else(|| "invalid account value".to_string())
1099    }
1100}
1101
1102#[allow(async_fn_in_trait)]
1103impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
1104    fn subscribe_user_fills(&self) -> BoxStream<Result<UserFill, String>> {
1105        let Some(addr) = self.user_address else {
1106            return Box::pin(stream::empty());
1107        };
1108        let addr_str = format!("{:#x}", addr);
1109        let sub = serde_json::json!({
1110            "method": "subscribe",
1111            "subscription": {"type": "userEvents", "user": addr_str.clone()}
1112        });
1113        let key = crate::ws::SubKey {
1114            channel: "userEvents".to_string(),
1115            routing_key: addr_str,
1116        };
1117        let raw_stream = self.ws_mux.subscribe(key, sub);
1118        Box::pin(raw_stream.filter_map(|text| async move {
1119            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1120                return None;
1121            };
1122            if env.channel != "userEvents" {
1123                return None;
1124            }
1125            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
1126                return None;
1127            };
1128            let items: Vec<_> = event
1129                .fills
1130                .unwrap_or_default()
1131                .into_iter()
1132                .filter_map(|fill| {
1133                    let side = if fill.side == "B" {
1134                        OrderSide::Buy
1135                    } else {
1136                        OrderSide::Sell
1137                    };
1138                    let price = parse_decimal(&fill.px)?;
1139                    let quantity = parse_decimal(&fill.sz)?;
1140                    let fee_usd = parse_decimal(&fill.fee)?;
1141                    Some(UserFill {
1142                        order_id: fill.oid,
1143                        symbol: fill.coin,
1144                        side,
1145                        price,
1146                        quantity,
1147                        fee_usd,
1148                        timestamp_ms: fill.time,
1149                        cloid: fill.cloid,
1150                    })
1151                })
1152                .collect();
1153            if items.is_empty() {
1154                None
1155            } else {
1156                Some(stream::iter(items.into_iter().map(Ok)))
1157            }
1158        }).flatten())
1159    }
1160
1161    fn subscribe_order_updates(&self) -> BoxStream<Result<OrderUpdate, String>> {
1162        let Some(addr) = self.user_address else {
1163            return Box::pin(stream::empty());
1164        };
1165        let addr_str = format!("{:#x}", addr);
1166        let sub = serde_json::json!({
1167            "method": "subscribe",
1168            "subscription": {"type": "orderUpdates", "user": addr_str.clone()}
1169        });
1170        let key = crate::ws::SubKey {
1171            channel: "orderUpdates".to_string(),
1172            routing_key: addr_str,
1173        };
1174        let raw_stream = self.ws_mux.subscribe(key, sub);
1175        Box::pin(raw_stream.filter_map(|text| async move {
1176            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1177                return None;
1178            };
1179            if env.channel != "orderUpdates" {
1180                return None;
1181            }
1182            let Ok(updates) = serde_json::from_value::<Vec<WsOrderUpdate>>(env.data) else {
1183                return None;
1184            };
1185            let items: Vec<_> = updates
1186                .into_iter()
1187                .map(|upd| {
1188                    let status = match upd.status.as_str() {
1189                        "open" => OrderStatus::Placed,
1190                        "filled" => OrderStatus::Filled,
1191                        "canceled" | "cancelled" => OrderStatus::Cancelled,
1192                        _ => OrderStatus::PartiallyFilled,
1193                    };
1194                    let side = if upd.order.side == "B" {
1195                        OrderSide::Buy
1196                    } else {
1197                        OrderSide::Sell
1198                    };
1199                    OrderUpdate {
1200                        order_id: upd.order.oid,
1201                        symbol: upd.order.coin,
1202                        status,
1203                        side: Some(side),
1204                        price: parse_decimal(&upd.order.limit_px),
1205                        quantity: parse_decimal(&upd.order.orig_sz),
1206                        remaining_quantity: parse_decimal(&upd.order.sz),
1207                        timestamp_ms: upd.status_timestamp,
1208                        cloid: upd.order.cloid,
1209                    }
1210                })
1211                .collect();
1212            if items.is_empty() {
1213                None
1214            } else {
1215                Some(stream::iter(items.into_iter().map(Ok)))
1216            }
1217        }).flatten())
1218    }
1219
1220    fn subscribe_funding_payments(&self) -> BoxStream<Result<FundingPayment, String>> {
1221        let Some(addr) = self.user_address else {
1222            return Box::pin(stream::empty());
1223        };
1224        let addr_str = format!("{:#x}", addr);
1225        let sub = serde_json::json!({
1226            "method": "subscribe",
1227            "subscription": {"type": "userEvents", "user": addr_str.clone()}
1228        });
1229        let key = crate::ws::SubKey {
1230            channel: "userEvents".to_string(),
1231            routing_key: addr_str,
1232        };
1233        let raw_stream = self.ws_mux.subscribe(key, sub);
1234        Box::pin(raw_stream.filter_map(|text| async move {
1235            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1236                return None;
1237            };
1238            if env.channel != "userEvents" {
1239                return None;
1240            }
1241            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
1242                return None;
1243            };
1244            let Some(funding) = event.funding else {
1245                return None;
1246            };
1247            let Some(amount_usd) = parse_decimal(&funding.usdc) else {
1248                return None;
1249            };
1250            let item = FundingPayment {
1251                symbol: funding.coin,
1252                amount_usd,
1253                timestamp_ms: funding.time,
1254            };
1255            Some(stream::iter(vec![Ok(item)].into_iter()))
1256        }).flatten())
1257    }
1258
1259    fn subscribe_deposits(&self) -> BoxStream<Result<Deposit, String>> {
1260        let Some(addr) = self.user_address else {
1261            return Box::pin(stream::empty());
1262        };
1263        let addr_str = format!("{:#x}", addr);
1264        let sub = serde_json::json!({
1265            "method": "subscribe",
1266            "subscription": {"type": "userNonFundingLedgerUpdates", "user": addr_str.clone()}
1267        });
1268        let key = crate::ws::SubKey {
1269            channel: "userNonFundingLedgerUpdates".to_string(),
1270            routing_key: addr_str,
1271        };
1272        let raw_stream = self.ws_mux.subscribe(key, sub);
1273        Box::pin(raw_stream.filter_map(|text| async move {
1274            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1275                return None;
1276            };
1277            if env.channel != "userNonFundingLedgerUpdates" {
1278                return None;
1279            }
1280            let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else {
1281                return None;
1282            };
1283            let items: Vec<_> = ledger
1284                .updates
1285                .into_iter()
1286                .filter_map(|e| {
1287                    if e.delta.kind != "deposit" {
1288                        return None;
1289                    }
1290                    let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1291                    Some(Deposit {
1292                        asset: "USDC".to_string(),
1293                        amount_usd,
1294                        timestamp_ms: e.time,
1295                    })
1296                })
1297                .collect();
1298            if items.is_empty() {
1299                None
1300            } else {
1301                Some(stream::iter(items.into_iter().map(Ok)))
1302            }
1303        }).flatten())
1304    }
1305
1306    fn subscribe_withdrawals(&self) -> BoxStream<Result<Withdrawal, String>> {
1307        let Some(addr) = self.user_address else {
1308            return Box::pin(stream::empty());
1309        };
1310        let addr_str = format!("{:#x}", addr);
1311        let sub = serde_json::json!({
1312            "method": "subscribe",
1313            "subscription": {"type": "userNonFundingLedgerUpdates", "user": addr_str.clone()}
1314        });
1315        let key = crate::ws::SubKey {
1316            channel: "userNonFundingLedgerUpdates".to_string(),
1317            routing_key: addr_str,
1318        };
1319        let raw_stream = self.ws_mux.subscribe(key, sub);
1320        Box::pin(raw_stream.filter_map(|text| async move {
1321            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1322                return None;
1323            };
1324            if env.channel != "userNonFundingLedgerUpdates" {
1325                return None;
1326            }
1327            let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else {
1328                return None;
1329            };
1330            let items: Vec<_> = ledger
1331                .updates
1332                .into_iter()
1333                .filter_map(|e| {
1334                    if e.delta.kind != "withdraw" {
1335                        return None;
1336                    }
1337                    let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1338                    Some(Withdrawal {
1339                        asset: "USDC".to_string(),
1340                        amount_usd,
1341                        timestamp_ms: e.time,
1342                    })
1343                })
1344                .collect();
1345            if items.is_empty() {
1346                None
1347            } else {
1348                Some(stream::iter(items.into_iter().map(Ok)))
1349            }
1350        }).flatten())
1351    }
1352}