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) -> Result<reqwest::Response, String> {
74        self.rest_limiter.acquire_blocking(weight).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) -> Result<reqwest::Response, String> {
86        self.rest_limiter.acquire_blocking(weight).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)
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).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}
296
297#[derive(Deserialize)]
298struct WsFunding {
299    time: i64,
300    coin: String,
301    usdc: String,
302}
303
304#[derive(Deserialize)]
305struct WsTrade {
306    coin: String,
307    side: String,
308    px: String,
309    sz: String,
310    time: i64,
311    tid: i64,
312}
313
314#[derive(Deserialize)]
315struct WsOrderUpdate {
316    order: WsOrderInfo,
317    status: String,
318    #[serde(rename = "statusTimestamp")]
319    status_timestamp: i64,
320}
321
322#[derive(Deserialize)]
323#[serde(rename_all = "camelCase")]
324struct WsOrderInfo {
325    coin: String,
326    side: String,
327    limit_px: String,
328    sz: String,
329    oid: i64,
330    orig_sz: String,
331}
332
333// --- WebSocket ledger update shapes (deposits / withdrawals) ---
334
335#[derive(Deserialize)]
336struct WsLedgerUpdates {
337    updates: Vec<WsLedgerEntry>,
338}
339
340#[derive(Deserialize)]
341struct WsLedgerEntry {
342    time: i64,
343    delta: WsLedgerDelta,
344}
345
346#[derive(Deserialize)]
347struct WsLedgerDelta {
348    #[serde(rename = "type")]
349    kind: String,
350    usdc: Option<String>,
351}
352
353// --- Helpers ---
354
355fn parse_decimal(s: &str) -> Option<Decimal> {
356    Decimal::from_str(s).ok()
357}
358
359fn keccak256(data: &[u8]) -> [u8; 32] {
360    use sha3::{Digest, Keccak256};
361    Keccak256::digest(data).into()
362}
363
364/// EIP-712 domain separator for Hyperliquid mainnet (Arbitrum, chainId=42161).
365fn hyperliquid_domain_separator() -> [u8; 32] {
366    let type_hash = keccak256(
367        b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
368    );
369    let name_hash = keccak256(b"Exchange");
370    let version_hash = keccak256(b"1");
371    let mut chain_id = [0u8; 32];
372    chain_id[28..32].copy_from_slice(&42161u32.to_be_bytes());
373    let verifying_contract = [0u8; 32];
374
375    let mut data = [0u8; 160];
376    data[..32].copy_from_slice(&type_hash);
377    data[32..64].copy_from_slice(&name_hash);
378    data[64..96].copy_from_slice(&version_hash);
379    data[96..128].copy_from_slice(&chain_id);
380    data[128..160].copy_from_slice(&verifying_contract);
381    keccak256(&data)
382}
383
384/// Signs a Hyperliquid exchange action using EIP-712.
385/// Returns (r, s, v) where r and s are "0x"-prefixed hex strings and v is 27 or 28.
386fn sign_action(
387    private_key: &str,
388    action: &Value,
389    vault_address: Option<&str>,
390    nonce: u64,
391) -> Result<(String, String, u8), String> {
392    use k256::ecdsa::SigningKey;
393
394    // Step 1: msgpack-encode the action, append nonce + vault flag
395    let msgpack_bytes = rmp_serde::to_vec(action).map_err(|e| e.to_string())?;
396    let mut data = msgpack_bytes;
397    data.extend_from_slice(&nonce.to_be_bytes());
398    match vault_address {
399        None => data.push(0u8),
400        Some(addr) => {
401            data.push(1u8);
402            let addr_bytes = hex::decode(addr.trim_start_matches("0x"))
403                .map_err(|e| format!("invalid vault address: {}", e))?;
404            data.extend_from_slice(&addr_bytes);
405        }
406    }
407    let connection_id = keccak256(&data);
408
409    // Step 2: hash the Agent struct
410    let agent_type_hash = keccak256(b"Agent(string source,bytes32 connectionId)");
411    let source_hash = keccak256(b"a"); // "a" = mainnet
412    let mut struct_data = [0u8; 96];
413    struct_data[..32].copy_from_slice(&agent_type_hash);
414    struct_data[32..64].copy_from_slice(&source_hash);
415    struct_data[64..96].copy_from_slice(&connection_id);
416    let struct_hash = keccak256(&struct_data);
417
418    // Step 3: EIP-712 final hash
419    let domain_sep = hyperliquid_domain_separator();
420    let mut final_data = Vec::with_capacity(66);
421    final_data.extend_from_slice(b"\x19\x01");
422    final_data.extend_from_slice(&domain_sep);
423    final_data.extend_from_slice(&struct_hash);
424    let final_hash = keccak256(&final_data);
425
426    // Step 4: sign with secp256k1
427    let key_bytes = hex::decode(private_key.trim_start_matches("0x"))
428        .map_err(|e| format!("invalid private key: {}", e))?;
429    let signing_key =
430        SigningKey::from_bytes(key_bytes.as_slice().into()).map_err(|e| e.to_string())?;
431    let (sig, recovery_id) = signing_key
432        .sign_prehash_recoverable(&final_hash)
433        .map_err(|e| e.to_string())?;
434
435    let sig_bytes = sig.to_bytes();
436    let r = format!("0x{}", hex::encode(&sig_bytes[..32]));
437    let s = format!("0x{}", hex::encode(&sig_bytes[32..64]));
438    let v = 27u8 + recovery_id.to_byte();
439
440    Ok((r, s, v))
441}
442
443// --- Trait implementations ---
444
445#[allow(async_fn_in_trait)]
446impl guilder_abstraction::TestServer for HyperliquidClient {
447    /// Sends a lightweight allMids request; returns true if the server responds 200 OK.
448    async fn ping(&self) -> Result<bool, String> {
449        // allMids → weight 2
450        self.info_post(serde_json::json!({"type": "allMids"}), 2)
451            .await
452            .map(|r| r.status().is_success())
453    }
454
455    /// Hyperliquid has no dedicated server-time endpoint; returns local UTC ms.
456    async fn get_server_time(&self) -> Result<i64, String> {
457        Ok(std::time::SystemTime::now()
458            .duration_since(std::time::UNIX_EPOCH)
459            .map(|d| d.as_millis() as i64)
460            .unwrap_or(0))
461    }
462}
463
464#[allow(async_fn_in_trait)]
465impl guilder_abstraction::GetMarketData for HyperliquidClient {
466    /// Returns all perpetual asset names from Hyperliquid's meta endpoint.
467    async fn get_symbol(&self) -> Result<Vec<String>, String> {
468        // meta → weight 20
469        let resp = self
470            .info_post(serde_json::json!({"type": "meta"}), 20)
471            .await?;
472        parse_response::<MetaResponse>(resp)
473            .await
474            .map(|r| r.universe.into_iter().map(|a| a.name).collect())
475    }
476
477    /// Returns the current open interest for `symbol` from metaAndAssetCtxs.
478    async fn get_open_interest(&self, symbol: String) -> Result<Decimal, String> {
479        // metaAndAssetCtxs → weight 20
480        let resp = self
481            .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20)
482            .await?;
483        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
484            .await?
485            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
486        meta.universe
487            .iter()
488            .position(|a| a.name == symbol)
489            .and_then(|i| ctxs.get(i))
490            .and_then(|ctx| parse_decimal(&ctx.open_interest))
491            .ok_or_else(|| format!("symbol {} not found", symbol))
492    }
493
494    /// Returns a full AssetContext snapshot for `symbol` from metaAndAssetCtxs.
495    async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
496        // metaAndAssetCtxs → weight 20
497        let resp = self
498            .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20)
499            .await?;
500        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
501            .await?
502            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
503        let idx = meta
504            .universe
505            .iter()
506            .position(|a| a.name == symbol)
507            .ok_or_else(|| format!("symbol {} not found", symbol))?;
508        let ctx = ctxs
509            .get(idx)
510            .ok_or_else(|| format!("symbol {} not found", symbol))?;
511        Ok(AssetContext {
512            symbol,
513            open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
514            funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
515            mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
516            day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
517            mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
518            oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
519            premium: ctx.premium.as_deref().and_then(parse_decimal),
520            prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
521        })
522    }
523
524    /// Fetches metaAndAssetCtxs once and returns all asset contexts in universe order.
525    /// Prefer this over repeated `get_asset_context` calls to avoid rate-limiting.
526    async fn get_all_asset_contexts(&self) -> Result<Vec<AssetContext>, String> {
527        // metaAndAssetCtxs → weight 20
528        let resp = self
529            .info_post(serde_json::json!({"type": "metaAndAssetCtxs"}), 20)
530            .await?;
531        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
532            .await?
533            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
534        let mut result = Vec::with_capacity(meta.universe.len());
535        for (asset, ctx) in meta.universe.iter().zip(ctxs.iter()) {
536            let Some(open_interest) = parse_decimal(&ctx.open_interest) else {
537                continue;
538            };
539            let Some(funding_rate) = parse_decimal(&ctx.funding) else {
540                continue;
541            };
542            let Some(mark_price) = parse_decimal(&ctx.mark_px) else {
543                continue;
544            };
545            let Some(day_volume) = parse_decimal(&ctx.day_ntl_vlm) else {
546                continue;
547            };
548            result.push(AssetContext {
549                symbol: asset.name.clone(),
550                open_interest,
551                funding_rate,
552                mark_price,
553                day_volume,
554                mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
555                oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
556                premium: ctx.premium.as_deref().and_then(parse_decimal),
557                prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
558            });
559        }
560        Ok(result)
561    }
562
563    /// Returns a full L2 orderbook snapshot for `symbol` from the l2Book REST endpoint.
564    /// Levels are returned as individual `L2Update` items; all share the same `sequence` (timestamp).
565    async fn get_l2_orderbook(&self, symbol: String) -> Result<Vec<L2Update>, String> {
566        // l2Book → weight 2
567        let resp = self
568            .info_post(serde_json::json!({"type": "l2Book", "coin": symbol}), 2)
569            .await?;
570        let book: Option<WsBook> = parse_response(resp).await?;
571        let book = match book {
572            Some(b) => b,
573            None => return Ok(vec![]),
574        };
575        let mut levels = Vec::new();
576        for level in book.levels.first().into_iter().flatten() {
577            if let (Some(price), Some(volume)) =
578                (parse_decimal(&level.px), parse_decimal(&level.sz))
579            {
580                levels.push(L2Update {
581                    symbol: book.coin.clone(),
582                    price,
583                    volume,
584                    side: Side::Ask,
585                    sequence: book.time,
586                });
587            }
588        }
589        for level in book.levels.get(1).into_iter().flatten() {
590            if let (Some(price), Some(volume)) =
591                (parse_decimal(&level.px), parse_decimal(&level.sz))
592            {
593                levels.push(L2Update {
594                    symbol: book.coin.clone(),
595                    price,
596                    volume,
597                    side: Side::Bid,
598                    sequence: book.time,
599                });
600            }
601        }
602        Ok(levels)
603    }
604
605    /// Returns the mid-price of `symbol` (e.g. "BTC") from allMids.
606    async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
607        // allMids → weight 2
608        let resp = self
609            .info_post(serde_json::json!({"type": "allMids"}), 2)
610            .await?;
611        parse_response::<HashMap<String, String>>(resp)
612            .await?
613            .get(&symbol)
614            .and_then(|s| parse_decimal(s))
615            .ok_or_else(|| format!("symbol {} not found", symbol))
616    }
617
618    /// Returns predicted funding rates for all symbols across all venues.
619    /// Null venue entries (unsupported coins) are silently skipped.
620    async fn get_predicted_fundings(&self) -> Result<Vec<PredictedFunding>, String> {
621        // predictedFundings → weight 20
622        let resp = self
623            .info_post(serde_json::json!({"type": "predictedFundings"}), 20)
624            .await?;
625        let data: PredictedFundingsResponse = parse_response(resp).await?;
626        let mut result = Vec::new();
627        for (symbol, venues) in data {
628            for (venue, entry) in venues {
629                let Some(entry) = entry else { continue };
630                if let Some(funding_rate) = parse_decimal(&entry.funding_rate) {
631                    result.push(PredictedFunding {
632                        symbol: symbol.clone(),
633                        venue,
634                        funding_rate,
635                        next_funding_time_ms: entry.next_funding_time,
636                    });
637                }
638            }
639        }
640        Ok(result)
641    }
642}
643
644#[allow(async_fn_in_trait)]
645impl guilder_abstraction::ManageOrder for HyperliquidClient {
646    /// Places an order on Hyperliquid. Requires `with_auth`. Returns an `OrderPlacement` with
647    /// the exchange-assigned order ID. Market orders are submitted as aggressive limit orders (IOC).
648    async fn place_order(
649        &self,
650        symbol: String,
651        side: OrderSide,
652        price: Decimal,
653        volume: Decimal,
654        order_type: OrderType,
655        time_in_force: TimeInForce,
656    ) -> Result<OrderPlacement, String> {
657        let asset_idx = self.get_asset_index(&symbol).await?;
658        let is_buy = matches!(side, OrderSide::Buy);
659
660        let tif_str = match time_in_force {
661            TimeInForce::Gtc => "Gtc",
662            TimeInForce::Ioc => "Ioc",
663            TimeInForce::Fok => "Fok",
664        };
665        // Market orders are IOC limit orders at a wide price
666        let order_type_val = match order_type {
667            OrderType::Limit => serde_json::json!({"limit": {"tif": tif_str}}),
668            OrderType::Market => serde_json::json!({"limit": {"tif": "Ioc"}}),
669        };
670
671        let action = serde_json::json!({
672            "type": "order",
673            "orders": [{
674                "a": asset_idx,
675                "b": is_buy,
676                "p": price.to_string(),
677                "s": volume.to_string(),
678                "r": false,
679                "t": order_type_val
680            }],
681            "grouping": "na"
682        });
683
684        let resp = self.submit_signed_action(action, None).await?;
685        let oid = resp["response"]["data"]["statuses"][0]["resting"]["oid"]
686            .as_i64()
687            .or_else(|| resp["response"]["data"]["statuses"][0]["filled"]["oid"].as_i64())
688            .ok_or_else(|| format!("unexpected response: {}", resp))?;
689
690        let timestamp_ms = std::time::SystemTime::now()
691            .duration_since(std::time::UNIX_EPOCH)
692            .unwrap()
693            .as_millis() as i64;
694
695        Ok(OrderPlacement {
696            order_id: oid,
697            symbol,
698            side,
699            price,
700            quantity: volume,
701            timestamp_ms,
702        })
703    }
704
705    /// Modifies price and size of an existing order by its order ID. Requires `with_auth`.
706    /// Fetches the order's current coin and side before submitting the modify action.
707    async fn change_order_by_cloid(
708        &self,
709        cloid: i64,
710        price: Decimal,
711        volume: Decimal,
712    ) -> Result<i64, String> {
713        let user = self.require_user_address()?;
714
715        // openOrders → weight 20; get_asset_index → meta weight 20
716        let resp = self
717            .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20)
718            .await?;
719        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
720        let order = orders
721            .iter()
722            .find(|o| o.oid == cloid)
723            .ok_or_else(|| format!("order {} not found", cloid))?;
724
725        let asset_idx = self.get_asset_index(&order.coin).await?;
726        let is_buy = order.side == "B";
727
728        let action = serde_json::json!({
729            "type": "batchModify",
730            "modifies": [{
731                "oid": cloid,
732                "order": {
733                    "a": asset_idx,
734                    "b": is_buy,
735                    "p": price.to_string(),
736                    "s": volume.to_string(),
737                    "r": false,
738                    "t": {"limit": {"tif": "Gtc"}}
739                }
740            }]
741        });
742
743        self.submit_signed_action(action, None).await?;
744        Ok(cloid)
745    }
746
747    /// Cancels a single order by its order ID. Requires `with_auth`.
748    /// Fetches open orders to resolve the coin/asset before cancelling.
749    async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
750        let user = self.require_user_address()?;
751
752        // openOrders → weight 20
753        let resp = self
754            .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20)
755            .await?;
756        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
757        let order = orders
758            .iter()
759            .find(|o| o.oid == cloid)
760            .ok_or_else(|| format!("order {} not found", cloid))?;
761
762        let asset_idx = self.get_asset_index(&order.coin).await?;
763        let action = serde_json::json!({
764            "type": "cancel",
765            "cancels": [{"a": asset_idx, "o": cloid}]
766        });
767
768        self.submit_signed_action(action, None).await?;
769        Ok(cloid)
770    }
771
772    /// Cancels all open orders. Requires `with_auth`.
773    /// Fetches all open orders and submits a batch cancel in a single signed request.
774    async fn cancel_all_order(&self) -> Result<bool, String> {
775        let user = self.require_user_address()?;
776
777        // openOrders → weight 20
778        let resp = self
779            .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20)
780            .await?;
781        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
782        if orders.is_empty() {
783            return Ok(true);
784        }
785
786        // meta → weight 20
787        let meta_resp = self
788            .info_post(serde_json::json!({"type": "meta"}), 20)
789            .await?;
790        let meta: MetaResponse = parse_response(meta_resp).await?;
791
792        let cancels: Vec<Value> = orders
793            .iter()
794            .filter_map(|o| {
795                let asset_idx = meta.universe.iter().position(|a| a.name == o.coin)?;
796                Some(serde_json::json!({"a": asset_idx, "o": o.oid}))
797            })
798            .collect();
799
800        let action = serde_json::json!({"type": "cancel", "cancels": cancels});
801        self.submit_signed_action(action, None).await?;
802        Ok(true)
803    }
804}
805
806#[allow(async_fn_in_trait)]
807impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
808    fn subscribe_l2_update(&self, symbol: String) -> BoxStream<Result<L2Update, String>> {
809        let sub = serde_json::json!({
810            "method": "subscribe",
811            "subscription": {"type": "l2Book", "coin": symbol.clone()}
812        });
813        let key = crate::ws::SubKey {
814            channel: "l2Book".to_string(),
815            routing_key: symbol,
816        };
817        let stream = self.ws_mux.subscribe(key, sub);
818        Box::pin(async_stream::stream! {
819            for await msg in stream {
820                let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
821                    continue;
822                };
823                if env.channel != "l2Book" {
824                    continue;
825                }
826                let Ok(book) = serde_json::from_value::<WsBook>(env.data) else {
827                    continue;
828                };
829                for level in book.levels.first().into_iter().flatten() {
830                    if let (Some(price), Some(volume)) =
831                        (parse_decimal(&level.px), parse_decimal(&level.sz))
832                    {
833                        yield Ok(L2Update {
834                            symbol: book.coin.clone(),
835                            price,
836                            volume,
837                            side: Side::Ask,
838                            sequence: book.time,
839                        });
840                    }
841                }
842                for level in book.levels.get(1).into_iter().flatten() {
843                    if let (Some(price), Some(volume)) =
844                        (parse_decimal(&level.px), parse_decimal(&level.sz))
845                    {
846                        yield Ok(L2Update {
847                            symbol: book.coin.clone(),
848                            price,
849                            volume,
850                            side: Side::Bid,
851                            sequence: book.time,
852                        });
853                    }
854                }
855            }
856        })
857    }
858
859    fn subscribe_asset_context(&self, symbol: String) -> BoxStream<Result<AssetContext, String>> {
860        let sub = serde_json::json!({
861            "method": "subscribe",
862            "subscription": {"type": "activeAssetCtx", "coin": symbol.clone()}
863        });
864        let key = crate::ws::SubKey {
865            channel: "activeAssetCtx".to_string(),
866            routing_key: symbol,
867        };
868        let stream = self.ws_mux.subscribe(key, sub);
869        Box::pin(async_stream::stream! {
870            for await msg in stream {
871                let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
872                    continue;
873                };
874                if env.channel != "activeAssetCtx" {
875                    continue;
876                }
877                let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else {
878                    continue;
879                };
880                let ctx = &update.ctx;
881                let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
882                    parse_decimal(&ctx.open_interest),
883                    parse_decimal(&ctx.funding),
884                    parse_decimal(&ctx.mark_px),
885                    parse_decimal(&ctx.day_ntl_vlm),
886                ) else {
887                    continue;
888                };
889                yield Ok(AssetContext {
890                    symbol: update.coin,
891                    open_interest,
892                    funding_rate,
893                    mark_price,
894                    day_volume,
895                    mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
896                    oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
897                    premium: ctx.premium.as_deref().and_then(parse_decimal),
898                    prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
899                });
900            }
901        })
902    }
903
904    fn subscribe_liquidation(&self, user: String) -> BoxStream<Result<Liquidation, String>> {
905        let sub = serde_json::json!({
906            "method": "subscribe",
907            "subscription": {"type": "userEvents", "user": user.clone()}
908        });
909        let key = crate::ws::SubKey {
910            channel: "userEvents".to_string(),
911            routing_key: user,
912        };
913        let raw_stream = self.ws_mux.subscribe(key, sub);
914        Box::pin(raw_stream.filter_map(|text| async move {
915            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
916                return None;
917            };
918            if env.channel != "userEvents" {
919                return None;
920            }
921            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
922                return None;
923            };
924            let Some(liq) = event.liquidation else {
925                return None;
926            };
927            let (Some(notional_position), Some(account_value)) = (
928                parse_decimal(&liq.liquidated_ntl_pos),
929                parse_decimal(&liq.liquidated_account_value),
930            ) else {
931                return None;
932            };
933            let item = Liquidation {
934                symbol: String::new(),
935                side: OrderSide::Sell,
936                liquidated_user: liq.liquidated_user,
937                notional_position,
938                account_value,
939            };
940            Some(stream::iter(vec![Ok(item)].into_iter()))
941        }).flatten())
942    }
943
944    fn subscribe_fill(&self, symbol: String) -> BoxStream<Result<Fill, String>> {
945        let sub = serde_json::json!({
946            "method": "subscribe",
947            "subscription": {"type": "trades", "coin": symbol.clone()}
948        });
949        let key = crate::ws::SubKey {
950            channel: "trades".to_string(),
951            routing_key: symbol,
952        };
953        let stream = self.ws_mux.subscribe(key, sub);
954        Box::pin(async_stream::stream! {
955            for await msg in stream {
956                let Ok(env) = serde_json::from_str::<WsEnvelope>(&msg) else {
957                    continue;
958                };
959                if env.channel != "trades" {
960                    continue;
961                }
962                let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else {
963                    continue;
964                };
965                for trade in trades {
966                    let side = if trade.side == "B" {
967                        OrderSide::Buy
968                    } else {
969                        OrderSide::Sell
970                    };
971                    let price = parse_decimal(&trade.px);
972                    let volume = parse_decimal(&trade.sz);
973                    if let (Some(price), Some(volume)) = (price, volume) {
974                        yield Ok(Fill {
975                            symbol: trade.coin,
976                            price,
977                            volume,
978                            side,
979                            timestamp_ms: trade.time,
980                            trade_id: trade.tid,
981                        });
982                    }
983                }
984            }
985        })
986    }
987}
988
989#[allow(async_fn_in_trait)]
990impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
991    /// Returns open positions from `clearinghouseState`. Requires `with_auth`.
992    /// Zero-size positions are filtered out. Positive `szi` = long, negative = short.
993    async fn get_positions(&self) -> Result<Vec<Position>, String> {
994        let user = self.require_user_address()?;
995        // clearinghouseState → weight 2
996        let resp = self
997            .info_post(
998                serde_json::json!({"type": "clearinghouseState", "user": user}),
999                2,
1000            )
1001            .await?;
1002        let state: ClearinghouseStateResponse = parse_response(resp).await?;
1003
1004        Ok(state
1005            .asset_positions
1006            .into_iter()
1007            .filter_map(|ap| {
1008                let p = ap.position;
1009                let size = parse_decimal(&p.szi)?;
1010                if size.is_zero() {
1011                    return None;
1012                }
1013                let entry_price = p
1014                    .entry_px
1015                    .as_deref()
1016                    .and_then(parse_decimal)
1017                    .unwrap_or_default();
1018                let side = if size > Decimal::ZERO {
1019                    OrderSide::Buy
1020                } else {
1021                    OrderSide::Sell
1022                };
1023                Some(Position {
1024                    symbol: p.coin,
1025                    side,
1026                    size: size.abs(),
1027                    entry_price,
1028                })
1029            })
1030            .collect())
1031    }
1032
1033    /// Returns resting orders from Hyperliquid's `openOrders` endpoint. Requires `with_auth`.
1034    /// `filled_quantity` is derived as `origSz - sz` (original size minus remaining size).
1035    async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
1036        let user = self.require_user_address()?;
1037        // openOrders → weight 20
1038        let resp = self
1039            .info_post(serde_json::json!({"type": "openOrders", "user": user}), 20)
1040            .await?;
1041        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
1042
1043        Ok(orders
1044            .into_iter()
1045            .filter_map(|o| {
1046                let price = parse_decimal(&o.limit_px)?;
1047                let quantity = parse_decimal(&o.orig_sz)?;
1048                let remaining = parse_decimal(&o.sz)?;
1049                let filled_quantity = quantity - remaining;
1050                let side = if o.side == "B" {
1051                    OrderSide::Buy
1052                } else {
1053                    OrderSide::Sell
1054                };
1055                Some(OpenOrder {
1056                    order_id: o.oid,
1057                    symbol: o.coin,
1058                    side,
1059                    price,
1060                    quantity,
1061                    filled_quantity,
1062                })
1063            })
1064            .collect())
1065    }
1066
1067    /// Returns total account value (collateral) from `clearinghouseState`. Requires `with_auth`.
1068    async fn get_collateral(&self) -> Result<Decimal, String> {
1069        let user = self.require_user_address()?;
1070        // clearinghouseState → weight 2
1071        let resp = self
1072            .info_post(
1073                serde_json::json!({"type": "clearinghouseState", "user": user}),
1074                2,
1075            )
1076            .await?;
1077        let state: ClearinghouseStateResponse = parse_response(resp).await?;
1078        parse_decimal(&state.margin_summary.account_value)
1079            .ok_or_else(|| "invalid account value".to_string())
1080    }
1081}
1082
1083#[allow(async_fn_in_trait)]
1084impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
1085    fn subscribe_user_fills(&self) -> BoxStream<Result<UserFill, String>> {
1086        let Some(addr) = self.user_address else {
1087            return Box::pin(stream::empty());
1088        };
1089        let addr_str = format!("{:#x}", addr);
1090        let sub = serde_json::json!({
1091            "method": "subscribe",
1092            "subscription": {"type": "userEvents", "user": addr_str.clone()}
1093        });
1094        let key = crate::ws::SubKey {
1095            channel: "userEvents".to_string(),
1096            routing_key: addr_str,
1097        };
1098        let raw_stream = self.ws_mux.subscribe(key, sub);
1099        Box::pin(raw_stream.filter_map(|text| async move {
1100            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1101                return None;
1102            };
1103            if env.channel != "userEvents" {
1104                return None;
1105            }
1106            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
1107                return None;
1108            };
1109            let items: Vec<_> = event
1110                .fills
1111                .unwrap_or_default()
1112                .into_iter()
1113                .filter_map(|fill| {
1114                    let side = if fill.side == "B" {
1115                        OrderSide::Buy
1116                    } else {
1117                        OrderSide::Sell
1118                    };
1119                    let price = parse_decimal(&fill.px)?;
1120                    let quantity = parse_decimal(&fill.sz)?;
1121                    let fee_usd = parse_decimal(&fill.fee)?;
1122                    Some(UserFill {
1123                        order_id: fill.oid,
1124                        symbol: fill.coin,
1125                        side,
1126                        price,
1127                        quantity,
1128                        fee_usd,
1129                        timestamp_ms: fill.time,
1130                    })
1131                })
1132                .collect();
1133            if items.is_empty() {
1134                None
1135            } else {
1136                Some(stream::iter(items.into_iter().map(Ok)))
1137            }
1138        }).flatten())
1139    }
1140
1141    fn subscribe_order_updates(&self) -> BoxStream<Result<OrderUpdate, String>> {
1142        let Some(addr) = self.user_address else {
1143            return Box::pin(stream::empty());
1144        };
1145        let addr_str = format!("{:#x}", addr);
1146        let sub = serde_json::json!({
1147            "method": "subscribe",
1148            "subscription": {"type": "orderUpdates", "user": addr_str.clone()}
1149        });
1150        let key = crate::ws::SubKey {
1151            channel: "orderUpdates".to_string(),
1152            routing_key: addr_str,
1153        };
1154        let raw_stream = self.ws_mux.subscribe(key, sub);
1155        Box::pin(raw_stream.filter_map(|text| async move {
1156            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1157                return None;
1158            };
1159            if env.channel != "orderUpdates" {
1160                return None;
1161            }
1162            let Ok(updates) = serde_json::from_value::<Vec<WsOrderUpdate>>(env.data) else {
1163                return None;
1164            };
1165            let items: Vec<_> = updates
1166                .into_iter()
1167                .map(|upd| {
1168                    let status = match upd.status.as_str() {
1169                        "open" => OrderStatus::Placed,
1170                        "filled" => OrderStatus::Filled,
1171                        "canceled" | "cancelled" => OrderStatus::Cancelled,
1172                        _ => OrderStatus::PartiallyFilled,
1173                    };
1174                    let side = if upd.order.side == "B" {
1175                        OrderSide::Buy
1176                    } else {
1177                        OrderSide::Sell
1178                    };
1179                    OrderUpdate {
1180                        order_id: upd.order.oid,
1181                        symbol: upd.order.coin,
1182                        status,
1183                        side: Some(side),
1184                        price: parse_decimal(&upd.order.limit_px),
1185                        quantity: parse_decimal(&upd.order.orig_sz),
1186                        remaining_quantity: parse_decimal(&upd.order.sz),
1187                        timestamp_ms: upd.status_timestamp,
1188                    }
1189                })
1190                .collect();
1191            if items.is_empty() {
1192                None
1193            } else {
1194                Some(stream::iter(items.into_iter().map(Ok)))
1195            }
1196        }).flatten())
1197    }
1198
1199    fn subscribe_funding_payments(&self) -> BoxStream<Result<FundingPayment, String>> {
1200        let Some(addr) = self.user_address else {
1201            return Box::pin(stream::empty());
1202        };
1203        let addr_str = format!("{:#x}", addr);
1204        let sub = serde_json::json!({
1205            "method": "subscribe",
1206            "subscription": {"type": "userEvents", "user": addr_str.clone()}
1207        });
1208        let key = crate::ws::SubKey {
1209            channel: "userEvents".to_string(),
1210            routing_key: addr_str,
1211        };
1212        let raw_stream = self.ws_mux.subscribe(key, sub);
1213        Box::pin(raw_stream.filter_map(|text| async move {
1214            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1215                return None;
1216            };
1217            if env.channel != "userEvents" {
1218                return None;
1219            }
1220            let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else {
1221                return None;
1222            };
1223            let Some(funding) = event.funding else {
1224                return None;
1225            };
1226            let Some(amount_usd) = parse_decimal(&funding.usdc) else {
1227                return None;
1228            };
1229            let item = FundingPayment {
1230                symbol: funding.coin,
1231                amount_usd,
1232                timestamp_ms: funding.time,
1233            };
1234            Some(stream::iter(vec![Ok(item)].into_iter()))
1235        }).flatten())
1236    }
1237
1238    fn subscribe_deposits(&self) -> BoxStream<Result<Deposit, String>> {
1239        let Some(addr) = self.user_address else {
1240            return Box::pin(stream::empty());
1241        };
1242        let addr_str = format!("{:#x}", addr);
1243        let sub = serde_json::json!({
1244            "method": "subscribe",
1245            "subscription": {"type": "userNonFundingLedgerUpdates", "user": addr_str.clone()}
1246        });
1247        let key = crate::ws::SubKey {
1248            channel: "userNonFundingLedgerUpdates".to_string(),
1249            routing_key: addr_str,
1250        };
1251        let raw_stream = self.ws_mux.subscribe(key, sub);
1252        Box::pin(raw_stream.filter_map(|text| async move {
1253            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1254                return None;
1255            };
1256            if env.channel != "userNonFundingLedgerUpdates" {
1257                return None;
1258            }
1259            let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else {
1260                return None;
1261            };
1262            let items: Vec<_> = ledger
1263                .updates
1264                .into_iter()
1265                .filter_map(|e| {
1266                    if e.delta.kind != "deposit" {
1267                        return None;
1268                    }
1269                    let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1270                    Some(Deposit {
1271                        asset: "USDC".to_string(),
1272                        amount_usd,
1273                        timestamp_ms: e.time,
1274                    })
1275                })
1276                .collect();
1277            if items.is_empty() {
1278                None
1279            } else {
1280                Some(stream::iter(items.into_iter().map(Ok)))
1281            }
1282        }).flatten())
1283    }
1284
1285    fn subscribe_withdrawals(&self) -> BoxStream<Result<Withdrawal, String>> {
1286        let Some(addr) = self.user_address else {
1287            return Box::pin(stream::empty());
1288        };
1289        let addr_str = format!("{:#x}", addr);
1290        let sub = serde_json::json!({
1291            "method": "subscribe",
1292            "subscription": {"type": "userNonFundingLedgerUpdates", "user": addr_str.clone()}
1293        });
1294        let key = crate::ws::SubKey {
1295            channel: "userNonFundingLedgerUpdates".to_string(),
1296            routing_key: addr_str,
1297        };
1298        let raw_stream = self.ws_mux.subscribe(key, sub);
1299        Box::pin(raw_stream.filter_map(|text| async move {
1300            let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else {
1301                return None;
1302            };
1303            if env.channel != "userNonFundingLedgerUpdates" {
1304                return None;
1305            }
1306            let Ok(ledger) = serde_json::from_value::<WsLedgerUpdates>(env.data) else {
1307                return None;
1308            };
1309            let items: Vec<_> = ledger
1310                .updates
1311                .into_iter()
1312                .filter_map(|e| {
1313                    if e.delta.kind != "withdraw" {
1314                        return None;
1315                    }
1316                    let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
1317                    Some(Withdrawal {
1318                        asset: "USDC".to_string(),
1319                        amount_usd,
1320                        timestamp_ms: e.time,
1321                    })
1322                })
1323                .collect();
1324            if items.is_empty() {
1325                None
1326            } else {
1327                Some(stream::iter(items.into_iter().map(Ok)))
1328            }
1329        }).flatten())
1330    }
1331}