Skip to main content

guilder_client_hyperliquid/
client.rs

1use crate::rate_limiter::{AddressRateLimiter, RestRateLimiter};
2use crate::ws::manager::{
3    managed_stream, HyperliquidSubscription, HyperliquidWsManager, WsSendRateLimiter,
4};
5use crate::ws::{HyperliquidWsBook, HyperliquidWsInboundMessage};
6use async_trait::async_trait;
7use futures_util::{stream, StreamExt};
8use guilder_abstraction::{
9    self, AssetContext, BoxStream, Deposit, EcdsaSignature, ExternalSigner, Fill, FundingPayment,
10    L2Level, L2Snapshot, L2Update, Liquidation, OpenOrder, OrderPlacement, OrderSide, OrderType,
11    OrderUpdate, Position, PredictedFunding, TimeInForce, UserFill, Withdrawal,
12};
13use reqwest::Client;
14use rust_decimal::Decimal;
15use serde::Deserialize;
16use serde_json::Value;
17use std::collections::HashMap;
18use std::str::FromStr;
19use std::sync::{Arc, RwLock};
20const HYPERLIQUID_INFO_URL: &str = "https://api.hyperliquid.xyz/info";
21const HYPERLIQUID_EXCHANGE_URL: &str = "https://api.hyperliquid.xyz/exchange";
22
23/// REST/WS endpoints, selectable at construction time. `Mainnet` is the
24/// default; `Testnet` points at the official testnet replica
25/// (api.hyperliquid-testnet.xyz) — real order-book mechanics, simulated
26/// balances (faucet-funded). Configurable per Sho 2026-09-21: cleanest at
27/// client construction, not via env or compile-time flags.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum HyperliquidNetwork {
30    /// Production: api.hyperliquid.xyz
31    #[default]
32    Mainnet,
33    /// Official testnet replica: api.hyperliquid-testnet.xyz
34    Testnet,
35}
36
37impl HyperliquidNetwork {
38    pub fn info_url(&self) -> &'static str {
39        match self {
40            Self::Mainnet => HYPERLIQUID_INFO_URL,
41            Self::Testnet => "https://api.hyperliquid-testnet.xyz/info",
42        }
43    }
44    pub fn exchange_url(&self) -> &'static str {
45        match self {
46            Self::Mainnet => HYPERLIQUID_EXCHANGE_URL,
47            Self::Testnet => "https://api.hyperliquid-testnet.xyz/exchange",
48        }
49    }
50    pub fn ws_url(&self) -> &'static str {
51        match self {
52            Self::Mainnet => "wss://api.hyperliquid.xyz/ws",
53            Self::Testnet => "wss://api.hyperliquid-testnet.xyz/ws",
54        }
55    }
56    /// EIP-712 phantom-agent `source` for L1 action signing.
57    /// Hyperliquid mainnet uses "a", testnet uses "b" — matches the official
58    /// Python SDK (`construct_phantom_agent`).
59    pub fn eip712_source(&self) -> &'static str {
60        match self {
61            Self::Mainnet => "a",
62            Self::Testnet => "b",
63        }
64    }
65}
66
67async fn parse_response<T: for<'de> serde::Deserialize<'de>>(
68    resp: reqwest::Response,
69) -> Result<T, String> {
70    let status = resp.status();
71    let text = resp
72        .text()
73        .await
74        .map_err(|e| format!("failed to read response body (status {status}): {e}"))?;
75
76    if text.is_empty() {
77        return Err(format!(
78            "empty response body from Hyperliquid (HTTP {status})"
79        ));
80    }
81
82    serde_json::from_str(&text).map_err(|e| {
83        let snippet = if text.len() > 512 {
84            format!("{}... ({} bytes total)", &text[..256], text.len())
85        } else {
86            text.clone()
87        };
88        format!("deserialize error (HTTP {status}): {e}: {snippet}")
89    })
90}
91
92pub struct HyperliquidClient {
93    client: Client,
94    network: HyperliquidNetwork,
95    user_address: Option<String>,
96    private_key: Option<String>,
97    external_signer: Option<Arc<dyn ExternalSigner>>,
98    rest_limiter: Arc<RestRateLimiter>,
99    address_limiter: Arc<AddressRateLimiter>,
100    market_ws_manager: HyperliquidWsManager,
101    user_ws_managers: Arc<RwLock<HashMap<String, HyperliquidWsManager>>>,
102    ws_send_limiter: WsSendRateLimiter,
103}
104
105impl Default for HyperliquidClient {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl HyperliquidClient {
112    pub fn new() -> Self {
113        Self::with_network(HyperliquidNetwork::Mainnet)
114    }
115
116    /// M2 (Sho 2026-09-21): select the network (mainnet/testnet) at
117    /// construction. All REST + WS endpoints follow.
118    pub fn with_network(network: HyperliquidNetwork) -> Self {
119        let ws_send_limiter = WsSendRateLimiter::new();
120        HyperliquidClient {
121            client: Client::new(),
122            network,
123            user_address: None,
124            private_key: None,
125            external_signer: None,
126            rest_limiter: Arc::new(RestRateLimiter::new()),
127            address_limiter: Arc::new(AddressRateLimiter::new()),
128            market_ws_manager: HyperliquidWsManager::new(None, ws_send_limiter.clone(), network.ws_url()),
129            user_ws_managers: Arc::new(RwLock::new(HashMap::new())),
130            ws_send_limiter,
131        }
132    }
133
134    pub fn with_auth(user_address: impl Into<String>, private_key: String) -> Self {
135        Self::with_network_and_auth(HyperliquidNetwork::Mainnet, user_address, private_key)
136    }
137
138    pub fn with_network_and_auth(
139        network: HyperliquidNetwork,
140        user_address: impl Into<String>,
141        private_key: String,
142    ) -> Self {
143        let ws_send_limiter = WsSendRateLimiter::new();
144        HyperliquidClient {
145            client: Client::new(),
146            network,
147            user_address: Some(user_address.into()),
148            private_key: Some(private_key),
149            external_signer: None,
150            rest_limiter: Arc::new(RestRateLimiter::new()),
151            address_limiter: Arc::new(AddressRateLimiter::new()),
152            market_ws_manager: HyperliquidWsManager::new(None, ws_send_limiter.clone(), network.ws_url()),
153            user_ws_managers: Arc::new(RwLock::new(HashMap::new())),
154            ws_send_limiter,
155        }
156    }
157
158    /// Create a client authenticated via an external signer (TPM, Secure Enclave, etc.).
159    ///
160    /// The signer handles the actual ECDSA signing; the private key never leaves
161    /// the hardware. The client computes the EIP-712 digest and delegates signing
162    /// to the external signer.
163    pub fn with_external_signer(
164        user_address: impl Into<String>,
165        signer: Arc<dyn ExternalSigner>,
166    ) -> Self {
167        let ws_send_limiter = WsSendRateLimiter::new();
168        HyperliquidClient {
169            client: Client::new(),
170            network: HyperliquidNetwork::Mainnet,
171            user_address: Some(user_address.into()),
172            private_key: None,
173            external_signer: Some(signer),
174            rest_limiter: Arc::new(RestRateLimiter::new()),
175            address_limiter: Arc::new(AddressRateLimiter::new()),
176            market_ws_manager: HyperliquidWsManager::new(None, ws_send_limiter.clone(), HyperliquidNetwork::Mainnet.ws_url()),
177            user_ws_managers: Arc::new(RwLock::new(HashMap::new())),
178            ws_send_limiter,
179        }
180    }
181
182    pub fn with_network_and_external_signer(
183        network: HyperliquidNetwork,
184        user_address: impl Into<String>,
185        signer: Arc<dyn ExternalSigner>,
186    ) -> Self {
187        let ws_send_limiter = WsSendRateLimiter::new();
188        HyperliquidClient {
189            client: Client::new(),
190            network,
191            user_address: Some(user_address.into()),
192            private_key: None,
193            external_signer: Some(signer),
194            rest_limiter: Arc::new(RestRateLimiter::new()),
195            address_limiter: Arc::new(AddressRateLimiter::new()),
196            market_ws_manager: HyperliquidWsManager::new(None, ws_send_limiter.clone(), network.ws_url()),
197            user_ws_managers: Arc::new(RwLock::new(HashMap::new())),
198            ws_send_limiter,
199        }
200    }
201
202    /// Configure rate limit budgets (rest_weight/min, address_requests).
203    /// Defaults: 1200 rest weight/min, 10000 address requests.
204    pub fn with_budgets(mut self, rest_weight: u32, addr_budget: u64) -> Self {
205        self.rest_limiter = Arc::new(RestRateLimiter::new_with_budget(rest_weight));
206        self.address_limiter = Arc::new(AddressRateLimiter::new_with_budget(addr_budget));
207        self
208    }
209
210    /// POST to the info endpoint, consuming `weight` from the REST rate-limit budget.
211    /// Returns `Err("rate_limited: ...")` immediately if budget is exhausted — no retry.
212    /// Callers should handle gracefully (skip cycle, retry later, etc.).
213    async fn info_post(
214        &self,
215        body: Value,
216        weight: u32,
217        call: &str,
218    ) -> Result<reqwest::Response, String> {
219        self.rest_limiter.acquire(weight).await.map_err(|e| {
220            format!(
221                "rate_limited: info_post ({call}) budget exhausted, retry_after_ms={}",
222                e.retry_after.as_millis()
223            )
224        })?;
225        self.client
226            .post(self.network.info_url())
227            .json(&body)
228            .send()
229            .await
230            .map_err(|e| e.to_string())
231    }
232    fn require_user_address(&self) -> Result<String, String> {
233        self.user_address
234            .clone()
235            .ok_or_else(|| "user address required: use HyperliquidClient::with_auth".to_string())
236    }
237
238    fn require_private_key(&self) -> Result<Option<&str>, String> {
239        if self.external_signer.is_some() {
240            return Ok(None);
241        }
242        self.private_key
243            .as_deref()
244            .map(Some)
245            .ok_or_else(|| "private key or external signer required: use with_auth or with_external_signer".to_string())
246    }
247
248    async fn get_asset_index(&self, symbol: &str) -> Result<usize, String> {
249        // `meta` is an "all other info" request → weight 20
250        let resp = self
251            .info_post(serde_json::json!({"type": "meta"}), 20, "get_asset_index")
252            .await?;
253        let meta: MetaResponse = parse_response(resp).await?;
254        meta.universe
255            .iter()
256            .position(|a| a.name == symbol)
257            .ok_or_else(|| format!("symbol {} not found", symbol))
258    }
259
260    async fn submit_signed_action(
261        &self,
262        action: Value,
263        vault_address: Option<&str>,
264    ) -> Result<Value, String> {
265        let private_key = self.require_private_key()?;
266        let nonce = std::time::SystemTime::now()
267            .duration_since(std::time::UNIX_EPOCH)
268            .unwrap()
269            .as_millis() as u64;
270
271        let (r, s, v) = sign_action(
272            private_key,
273            self.external_signer.as_ref(),
274            &action,
275            vault_address,
276            nonce,
277            self.network.eip712_source(),
278        )
279        .await?;
280
281        let payload = serde_json::json!({
282            "action": action,
283            "nonce": nonce,
284            "signature": {"r": r, "s": s, "v": v},
285            "vaultAddress": null,
286            "expiresAfter": null
287        });
288
289        // Check both rate limiters non-blocking — fail fast, no retry.
290        self.rest_limiter.acquire(1).await.map_err(|e| {
291            format!(
292                "rate_limited: rest_weight exhausted, retry_after_ms={}",
293                e.retry_after.as_millis()
294            )
295        })?;
296        self.address_limiter.acquire(1, false).await.map_err(|e| {
297            format!(
298                "rate_limited: address quota exhausted, retry_after_ms={}",
299                e.retry_after.as_millis()
300            )
301        })?;
302
303        let resp = self
304            .client
305            .post(self.network.exchange_url())
306            .json(&payload)
307            .send()
308            .await
309            .map_err(|e| e.to_string())?;
310
311        let status = resp.status();
312        if !status.is_success() {
313            let text = resp.text().await.map_err(|e| e.to_string())?;
314            return Err(format!("HTTP {status}: {text}"));
315        }
316
317        let body: Value = parse_response(resp).await?;
318        if body["status"].as_str() == Some("err") {
319            return Err(body["response"]
320                .as_str()
321                .unwrap_or("unknown error")
322                .to_string());
323        }
324        Ok(body)
325    }
326}
327
328// --- REST deserialization types ---
329
330#[derive(Deserialize)]
331struct MetaResponse {
332    universe: Vec<AssetInfo>,
333}
334
335#[derive(Deserialize)]
336struct AssetInfo {
337    name: String,
338    #[serde(rename = "szDecimals")]
339    sz_decimals: i32,
340    #[serde(rename = "isDelisted", default)]
341    is_delisted: bool,
342}
343
344type MetaAndAssetCtxsResponse = (MetaResponse, Vec<RestAssetCtx>);
345
346#[derive(Deserialize)]
347#[serde(rename_all = "camelCase")]
348#[allow(dead_code)]
349struct RestAssetCtx {
350    open_interest: String,
351    funding: String,
352    mark_px: String,
353    day_ntl_vlm: String,
354    mid_px: Option<String>,
355    oracle_px: Option<String>,
356    premium: Option<String>,
357    prev_day_px: Option<String>,
358}
359
360#[derive(Deserialize)]
361#[serde(rename_all = "camelCase")]
362#[allow(dead_code)]
363struct ClearinghouseStateResponse {
364    margin_summary: MarginSummary,
365    asset_positions: Vec<AssetPosition>,
366}
367
368/// Kept for get_positions compatibility; margin_summary fields are unused since get_collateral was removed.
369#[derive(Deserialize)]
370#[serde(rename_all = "camelCase")]
371#[allow(dead_code)]
372struct MarginSummary {
373    account_value: String,
374    #[serde(default)]
375    total_ntl_pos: Option<String>,
376    #[serde(default)]
377    total_raw_usd: Option<String>,
378    #[serde(default)]
379    total_margin_used: Option<String>,
380}
381
382#[derive(Deserialize)]
383struct AssetPosition {
384    position: PositionDetail,
385}
386
387#[derive(Deserialize)]
388#[serde(rename_all = "camelCase")]
389struct PositionDetail {
390    coin: String,
391    /// positive = long, negative = short
392    szi: String,
393    entry_px: Option<String>,
394}
395
396#[derive(Deserialize)]
397#[serde(rename_all = "camelCase")]
398struct RestOpenOrder {
399    coin: String,
400    side: String,
401    limit_px: String,
402    sz: String,
403    oid: i64,
404    orig_sz: String,
405    cloid: Option<String>,
406}
407
408// predictedFundings response: Vec<(coin, Vec<(venue, entry_or_null)>)>
409// The API returns null for venues that don't list the coin.
410type PredictedFundingsResponse = Vec<(String, Vec<(String, Option<PredictedFundingEntry>)>)>;
411
412#[derive(Deserialize)]
413#[serde(rename_all = "camelCase")]
414struct PredictedFundingEntry {
415    funding_rate: String,
416    next_funding_time: i64,
417}
418
419// --- Helpers ---
420
421fn parse_decimal(s: &str) -> Option<Decimal> {
422    Decimal::from_str(s).ok()
423}
424
425fn keccak256(data: &[u8]) -> [u8; 32] {
426    use sha3::{Digest, Keccak256};
427    Keccak256::digest(data).into()
428}
429
430/// EIP-712 domain separator for Hyperliquid L1 actions (chainId=1337).
431fn hyperliquid_domain_separator() -> [u8; 32] {
432    let type_hash = keccak256(
433        b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
434    );
435    let name_hash = keccak256(b"Exchange");
436    let version_hash = keccak256(b"1");
437    let mut chain_id = [0u8; 32];
438    chain_id[28..32].copy_from_slice(&1337u32.to_be_bytes());
439    let verifying_contract = [0u8; 32];
440
441    let mut data = [0u8; 160];
442    data[..32].copy_from_slice(&type_hash);
443    data[32..64].copy_from_slice(&name_hash);
444    data[64..96].copy_from_slice(&version_hash);
445    data[96..128].copy_from_slice(&chain_id);
446    data[128..160].copy_from_slice(&verifying_contract);
447    keccak256(&data)
448}
449
450/// Convert a `serde_json::Value` to msgpack bytes, preserving the JSON map key order.
451/// This avoids rmp_serde's HashMap-based serialization which reorders map keys.
452fn value_to_msgpack(val: &Value) -> Vec<u8> {
453    match val {
454        Value::Null => vec![0xc0],
455        Value::Bool(true) => vec![0xc3],
456        Value::Bool(false) => vec![0xc2],
457        Value::Number(n) => {
458            if let Some(i) = n.as_i64() {
459                if i >= 0 {
460                    if i <= 127 {
461                        vec![i as u8]
462                    } else if i <= 255 {
463                        vec![0xcc, i as u8]
464                    } else if i <= 65535 {
465                        let mut buf = vec![0xcd];
466                        buf.extend_from_slice(&(i as u16).to_be_bytes());
467                        buf
468                    } else if i <= 4294967295 {
469                        let mut buf = vec![0xce];
470                        buf.extend_from_slice(&(i as u32).to_be_bytes());
471                        buf
472                    } else {
473                        let mut buf = vec![0xcf];
474                        buf.extend_from_slice(&(i as u64).to_be_bytes());
475                        buf
476                    }
477                } else if i >= -32 {
478                    vec![0xe0 | (i as u8)]
479                } else if i >= -128 {
480                    vec![0xd0, i as i8 as u8]
481                } else if i >= -32768 {
482                    let mut buf = vec![0xd1];
483                    buf.extend_from_slice(&(i as i16).to_be_bytes());
484                    buf
485                } else if i >= -2147483648 {
486                    let mut buf = vec![0xd2];
487                    buf.extend_from_slice(&(i as i32).to_be_bytes());
488                    buf
489                } else {
490                    let mut buf = vec![0xd3];
491                    buf.extend_from_slice(&i.to_be_bytes());
492                    buf
493                }
494            } else if let Some(f) = n.as_f64() {
495                let mut buf = vec![0xcb];
496                buf.extend_from_slice(&f.to_be_bytes());
497                buf
498            } else {
499                let u = n.as_u64().unwrap();
500                if u <= 127 {
501                    vec![u as u8]
502                } else if u <= 255 {
503                    vec![0xcc, u as u8]
504                } else if u <= 65535 {
505                    let mut buf = vec![0xcd];
506                    buf.extend_from_slice(&(u as u16).to_be_bytes());
507                    buf
508                } else if u <= 4294967295 {
509                    let mut buf = vec![0xce];
510                    buf.extend_from_slice(&(u as u32).to_be_bytes());
511                    buf
512                } else {
513                    let mut buf = vec![0xcf];
514                    buf.extend_from_slice(&u.to_be_bytes());
515                    buf
516                }
517            }
518        }
519        Value::String(s) => {
520            let bytes = s.as_bytes();
521            let len = bytes.len();
522            let mut buf = Vec::new();
523            if len <= 31 {
524                buf.push(0xa0 | (len as u8));
525            } else if len <= 255 {
526                buf.push(0xd9);
527                buf.push(len as u8);
528            } else if len <= 65535 {
529                buf.push(0xda);
530                buf.extend_from_slice(&(len as u16).to_be_bytes());
531            } else {
532                buf.push(0xdb);
533                buf.extend_from_slice(&(len as u32).to_be_bytes());
534            }
535            buf.extend_from_slice(bytes);
536            buf
537        }
538        Value::Array(arr) => {
539            let len = arr.len();
540            let mut buf = Vec::new();
541            if len <= 15 {
542                buf.push(0x90 | (len as u8));
543            } else if len <= 65535 {
544                buf.push(0xdc);
545                buf.extend_from_slice(&(len as u16).to_be_bytes());
546            } else {
547                buf.push(0xdd);
548                buf.extend_from_slice(&(len as u32).to_be_bytes());
549            }
550            for item in arr {
551                buf.extend_from_slice(&value_to_msgpack(item));
552            }
553            buf
554        }
555        Value::Object(map) => {
556            let len = map.len();
557            let mut buf = Vec::new();
558            if len <= 15 {
559                buf.push(0x80 | (len as u8));
560            } else if len <= 65535 {
561                buf.push(0xde);
562                buf.extend_from_slice(&(len as u16).to_be_bytes());
563            } else {
564                buf.push(0xdf);
565                buf.extend_from_slice(&(len as u32).to_be_bytes());
566            }
567            for (key, value) in map {
568                buf.extend_from_slice(&value_to_msgpack(&Value::String(key.clone())));
569                buf.extend_from_slice(&value_to_msgpack(value));
570            }
571            buf
572        }
573    }
574}
575
576/// Convert action to msgpack bytes preserving JSON map key insertion order
577/// (matching Python's msgpack dict ordering).
578fn action_to_canonical_msgpack(action: &Value) -> Result<Vec<u8>, String> {
579    Ok(value_to_msgpack(action))
580}
581
582/// Build msgpack for a single order with Python SDK field order:
583/// a, b, p, s, r, t, c(opt)
584fn build_order_msgpack(
585    asset_idx: usize,
586    is_buy: bool,
587    price: &str,
588    size: &str,
589    reduce_only: bool,
590    order_kind: &str,
591    tif: &[u8],
592    cloid: Option<&str>,
593) -> Vec<u8> {
594    let field_count = if cloid.is_some() { 7 } else { 6 };
595    let mut buf = Vec::new();
596    buf.push(0x80 | (field_count as u8)); // fixmap
597
598    // "a": asset_idx
599    buf.extend_from_slice(&value_to_msgpack(&Value::String("a".to_string())));
600    buf.extend_from_slice(&value_to_msgpack(&Value::Number(serde_json::Number::from(
601        asset_idx,
602    ))));
603
604    // "b": is_buy
605    buf.extend_from_slice(&value_to_msgpack(&Value::String("b".to_string())));
606    buf.push(if is_buy { 0xc3 } else { 0xc2 });
607
608    // "p": price
609    buf.extend_from_slice(&value_to_msgpack(&Value::String("p".to_string())));
610    buf.extend_from_slice(&value_to_msgpack(&Value::String(price.to_string())));
611
612    // "s": size (Python SDK puts s before r)
613    buf.extend_from_slice(&value_to_msgpack(&Value::String("s".to_string())));
614    buf.extend_from_slice(&value_to_msgpack(&Value::String(size.to_string())));
615
616    // "r": reduce_only
617    buf.extend_from_slice(&value_to_msgpack(&Value::String("r".to_string())));
618    buf.push(if reduce_only { 0xc3 } else { 0xc2 });
619
620    // "t": { order_kind: { "tif": tif_str } }
621    buf.extend_from_slice(&value_to_msgpack(&Value::String("t".to_string())));
622    // Inner: fixmap(1) with order_kind key
623    buf.push(0x81);
624    buf.extend_from_slice(&value_to_msgpack(&Value::String(order_kind.to_string())));
625    // Inner-inner: fixmap(1) with "tif" key
626    buf.push(0x81);
627    buf.extend_from_slice(&value_to_msgpack(&Value::String("tif".to_string())));
628    buf.extend_from_slice(&value_to_msgpack(&Value::String(
629        String::from_utf8_lossy(tif).to_string(),
630    )));
631
632    // "c": cloid (optional, appended at END per Python SDK)
633    if let Some(c) = cloid {
634        buf.extend_from_slice(&value_to_msgpack(&Value::String("c".to_string())));
635        buf.extend_from_slice(&value_to_msgpack(&Value::String(c.to_string())));
636    }
637
638    buf
639}
640
641/// Build msgpack for a trigger order (take profit / stop loss) with Python SDK field order.
642/// Trigger orders use: t = { "trigger": { "isMarket": bool, "triggerPx": str, "tpsl": "tp"|"sl" } }
643fn build_trigger_order_msgpack(
644    asset_idx: usize,
645    is_buy: bool,
646    price: &str,
647    size: &str,
648    reduce_only: bool,
649    trigger_px: &str,
650    is_market: bool,
651    tpsl: &str,
652    cloid: Option<&str>,
653) -> Vec<u8> {
654    let field_count = if cloid.is_some() { 7 } else { 6 };
655    let mut buf = Vec::new();
656    buf.push(0x80 | (field_count as u8)); // fixmap
657
658    // "a": asset_idx
659    buf.extend_from_slice(&value_to_msgpack(&Value::String("a".to_string())));
660    buf.extend_from_slice(&value_to_msgpack(&Value::Number(serde_json::Number::from(
661        asset_idx,
662    ))));
663
664    // "b": is_buy
665    buf.extend_from_slice(&value_to_msgpack(&Value::String("b".to_string())));
666    buf.push(if is_buy { 0xc3 } else { 0xc2 });
667
668    // "p": price (use trigger_px as price for resting, or "0" for market-on-trigger)
669    buf.extend_from_slice(&value_to_msgpack(&Value::String("p".to_string())));
670    buf.extend_from_slice(&value_to_msgpack(&Value::String(price.to_string())));
671
672    // "s": size
673    buf.extend_from_slice(&value_to_msgpack(&Value::String("s".to_string())));
674    buf.extend_from_slice(&value_to_msgpack(&Value::String(size.to_string())));
675
676    // "r": reduce_only
677    buf.extend_from_slice(&value_to_msgpack(&Value::String("r".to_string())));
678    buf.push(if reduce_only { 0xc3 } else { 0xc2 });
679
680    // "t": { "trigger": { "isMarket": bool, "triggerPx": str, "tpsl": str } }
681    buf.extend_from_slice(&value_to_msgpack(&Value::String("t".to_string())));
682    buf.push(0x81); // fixmap(1): "trigger"
683    buf.extend_from_slice(&value_to_msgpack(&Value::String("trigger".to_string())));
684    // trigger object: fixmap(3)
685    buf.push(0x83);
686    buf.extend_from_slice(&value_to_msgpack(&Value::String("isMarket".to_string())));
687    buf.push(if is_market { 0xc3 } else { 0xc2 });
688    buf.extend_from_slice(&value_to_msgpack(&Value::String("triggerPx".to_string())));
689    buf.extend_from_slice(&value_to_msgpack(&Value::String(trigger_px.to_string())));
690    buf.extend_from_slice(&value_to_msgpack(&Value::String("tpsl".to_string())));
691    buf.extend_from_slice(&value_to_msgpack(&Value::String(tpsl.to_string())));
692
693    // "c": cloid (optional, appended at END)
694    if let Some(c) = cloid {
695        buf.extend_from_slice(&value_to_msgpack(&Value::String("c".to_string())));
696        buf.extend_from_slice(&value_to_msgpack(&Value::String(c.to_string())));
697    }
698
699    buf
700}
701
702/// Compute the EIP-712 digest for a Hyperliquid exchange action.
703///
704/// This is the hash that gets signed — extracted so both direct key and
705/// external signer paths can share it.
706fn compute_eip712_digest(
707    msgpack: &[u8],
708    nonce: u64,
709    vault_address: Option<&str>,
710    source: &str,
711) -> Result<[u8; 32], String> {
712    let mut data = msgpack.to_vec();
713    data.extend_from_slice(&nonce.to_be_bytes());
714    match vault_address {
715        None => data.push(0u8),
716        Some(addr) => {
717            data.push(1u8);
718            let addr_bytes = hex::decode(addr.trim_start_matches("0x"))
719                .map_err(|e| format!("invalid vault address: {}", e))?;
720            data.extend_from_slice(&addr_bytes);
721        }
722    }
723
724    let connection_id = keccak256(&data);
725    let agent_type_hash = keccak256(b"Agent(string source,bytes32 connectionId)");
726    let source_hash = keccak256(source.as_bytes());
727    let mut struct_data = [0u8; 96];
728    struct_data[..32].copy_from_slice(&agent_type_hash);
729    struct_data[32..64].copy_from_slice(&source_hash);
730    struct_data[64..96].copy_from_slice(&connection_id);
731    let struct_hash = keccak256(&struct_data);
732
733    let domain_sep = hyperliquid_domain_separator();
734    let mut final_data = Vec::with_capacity(66);
735    final_data.extend_from_slice(b"\x19\x01");
736    final_data.extend_from_slice(&domain_sep);
737    final_data.extend_from_slice(&struct_hash);
738    Ok(keccak256(&final_data))
739}
740
741/// Format an EcdsaSignature into the (r, s, v) hex strings expected by Hyperliquid.
742fn format_ecdsa_signature(sig: &EcdsaSignature) -> (String, String, u8) {
743    let r = format!("0x{}", hex::encode(sig.r));
744    let s = format!("0x{}", hex::encode(sig.s));
745    let v = 27u8 + sig.v;
746    (r, s, v)
747}
748
749/// Sign a digest using a raw private key (direct key mode).
750fn sign_digest_with_key(
751    digest: &[u8; 32],
752    private_key: &str,
753) -> Result<(String, String, u8), String> {
754    use k256::ecdsa::SigningKey;
755
756    let key_bytes = hex::decode(private_key.trim_start_matches("0x"))
757        .map_err(|e| format!("invalid private key: {}", e))?;
758    let signing_key =
759        SigningKey::from_bytes(key_bytes.as_slice().into()).map_err(|e| e.to_string())?;
760    let (sig, recovery_id) = signing_key
761        .sign_prehash_recoverable(digest)
762        .map_err(|e| e.to_string())?;
763
764    let sig_bytes = sig.to_bytes();
765    let r = format!("0x{}", hex::encode(&sig_bytes[..32]));
766    let s = format!("0x{}", hex::encode(&sig_bytes[32..64]));
767    let v = 27u8 + recovery_id.to_byte();
768
769    Ok((r, s, v))
770}
771
772/// Sign using pre-built msgpack bytes (bypassing serde_json field ordering).
773/// Supports both direct private key and external signer.
774async fn sign_with_msgpack(
775    msgpack: &[u8],
776    private_key: Option<&str>,
777    external_signer: Option<&Arc<dyn ExternalSigner>>,
778    nonce: u64,
779    vault_address: Option<&str>,
780    source: &str,
781) -> Result<(String, String, u8), String> {
782    let digest = compute_eip712_digest(msgpack, nonce, vault_address, source)?;
783
784    if let Some(signer) = external_signer {
785        let sig = signer.sign_prehash(&digest).await?;
786        return Ok(format_ecdsa_signature(&sig));
787    }
788
789    let key = private_key.ok_or_else(|| {
790        "no signing method available: provide private_key or external_signer".to_string()
791    })?;
792    sign_digest_with_key(&digest, key)
793}
794
795/// Signs a Hyperliquid exchange action using EIP-712.
796/// Returns (r, s, v) where r and s are "0x"-prefixed hex strings and v is 27 or 28.
797/// Supports both direct private key and external signer.
798async fn sign_action(
799    private_key: Option<&str>,
800    external_signer: Option<&Arc<dyn ExternalSigner>>,
801    action: &Value,
802    vault_address: Option<&str>,
803    nonce: u64,
804    source: &str,
805) -> Result<(String, String, u8), String> {
806    let msgpack_bytes = action_to_canonical_msgpack(action)?;
807    let digest = compute_eip712_digest(&msgpack_bytes, nonce, vault_address, source)?;
808
809    if let Some(signer) = external_signer {
810        let sig = signer.sign_prehash(&digest).await?;
811        return Ok(format_ecdsa_signature(&sig));
812    }
813
814    let key = private_key.ok_or_else(|| {
815        "no signing method available: provide private_key or external_signer".to_string()
816    })?;
817    sign_digest_with_key(&digest, key)
818}
819
820// --- Trait implementations ---
821
822#[async_trait]
823impl guilder_abstraction::TestServer for HyperliquidClient {
824    /// Sends a lightweight allMids request; returns true if the server responds 200 OK.
825    async fn ping(&self) -> Result<bool, String> {
826        // allMids → weight 2
827        self.info_post(serde_json::json!({"type": "allMids"}), 2, "ping")
828            .await
829            .map(|r| r.status().is_success())
830    }
831
832    /// Hyperliquid has no dedicated server-time endpoint; returns local UTC ms.
833    async fn get_server_time(&self) -> Result<i64, String> {
834        Ok(std::time::SystemTime::now()
835            .duration_since(std::time::UNIX_EPOCH)
836            .map(|d| d.as_millis() as i64)
837            .unwrap_or(0))
838    }
839}
840
841#[async_trait]
842impl guilder_abstraction::GetMarketData for HyperliquidClient {
843    /// Returns all perpetual asset names from Hyperliquid's meta endpoint.
844    async fn get_symbol(&self) -> Result<Vec<String>, String> {
845        // meta → weight 20
846        let resp = self
847            .info_post(serde_json::json!({"type": "meta"}), 20, "get_symbol")
848            .await?;
849        parse_response::<MetaResponse>(resp)
850            .await
851            .map(|r| r.universe.into_iter().map(|a| a.name).collect())
852    }
853
854    /// Returns the current open interest for `symbol` from metaAndAssetCtxs.
855    async fn get_open_interest(&self, symbol: String) -> Result<Decimal, String> {
856        // metaAndAssetCtxs → weight 20
857        let resp = self
858            .info_post(
859                serde_json::json!({"type": "metaAndAssetCtxs"}),
860                20,
861                "get_open_interest",
862            )
863            .await?;
864        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
865            .await?
866            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
867        meta.universe
868            .iter()
869            .position(|a| a.name == symbol)
870            .and_then(|i| ctxs.get(i))
871            .and_then(|ctx| parse_decimal(&ctx.open_interest))
872            .ok_or_else(|| format!("symbol {} not found", symbol))
873    }
874
875    /// Returns a full AssetContext snapshot for `symbol` from metaAndAssetCtxs.
876    async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
877        // metaAndAssetCtxs → weight 20
878        let resp = self
879            .info_post(
880                serde_json::json!({"type": "metaAndAssetCtxs"}),
881                20,
882                "get_asset_context",
883            )
884            .await?;
885        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
886            .await?
887            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
888        let idx = meta
889            .universe
890            .iter()
891            .position(|a| a.name == symbol)
892            .ok_or_else(|| format!("symbol {} not found", symbol))?;
893        let ctx = ctxs
894            .get(idx)
895            .ok_or_else(|| format!("symbol {} not found", symbol))?;
896        Ok(AssetContext {
897            symbol,
898            open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
899            funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
900            mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
901            day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
902            mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
903            oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
904            premium: ctx.premium.as_deref().and_then(parse_decimal),
905            prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
906            sz_decimals: meta.universe.get(idx).map(|a| a.sz_decimals).unwrap_or(0),
907        })
908    }
909
910    /// Fetches metaAndAssetCtxs once and returns all asset contexts in universe order.
911    /// Prefer this over repeated `get_asset_context` calls to avoid rate-limiting.
912    async fn get_all_asset_contexts(&self) -> Result<Vec<AssetContext>, String> {
913        // metaAndAssetCtxs → weight 20
914        let resp = self
915            .info_post(
916                serde_json::json!({"type": "metaAndAssetCtxs"}),
917                20,
918                "get_all_asset_contexts",
919            )
920            .await?;
921        let (meta, ctxs) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
922            .await?
923            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
924        let mut result = Vec::with_capacity(meta.universe.len());
925        for (asset, ctx) in meta.universe.iter().zip(ctxs.iter()) {
926            let Some(open_interest) = parse_decimal(&ctx.open_interest) else {
927                continue;
928            };
929            let Some(funding_rate) = parse_decimal(&ctx.funding) else {
930                continue;
931            };
932            let Some(mark_price) = parse_decimal(&ctx.mark_px) else {
933                continue;
934            };
935            let Some(day_volume) = parse_decimal(&ctx.day_ntl_vlm) else {
936                continue;
937            };
938            result.push(AssetContext {
939                symbol: asset.name.clone(),
940                open_interest,
941                funding_rate,
942                mark_price,
943                day_volume,
944                mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
945                oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
946                premium: ctx.premium.as_deref().and_then(parse_decimal),
947                prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
948                sz_decimals: asset.sz_decimals,
949            });
950        }
951        Ok(result)
952    }
953
954    /// Returns the number of decimal places for order size for a symbol.
955    async fn get_sz_decimals(&self, symbol: String) -> Result<i32, String> {
956        let all = self.get_all_sz_decimals().await?;
957        all.get(&symbol)
958            .copied()
959            .ok_or_else(|| format!("symbol {} not found", symbol))
960    }
961
962    /// Returns sz_decimals for all symbols from the meta universe.
963    async fn get_all_sz_decimals(&self) -> Result<HashMap<String, i32>, String> {
964        // metaAndAssetCtxs → weight 20
965        let resp = self
966            .info_post(
967                serde_json::json!({"type": "metaAndAssetCtxs"}),
968                20,
969                "get_all_sz_decimals",
970            )
971            .await?;
972        let (meta, _) = parse_response::<Option<MetaAndAssetCtxsResponse>>(resp)
973            .await?
974            .ok_or_else(|| "metaAndAssetCtxs returned null".to_string())?;
975        Ok(meta
976            .universe
977            .into_iter()
978            .map(|a| (a.name, a.sz_decimals))
979            .collect())
980    }
981
982    /// Returns a full L2 orderbook snapshot for `symbol` from the l2Book REST endpoint.
983    async fn get_l2_orderbook(&self, symbol: String) -> Result<L2Snapshot, String> {
984        // l2Book → weight 2
985        let resp = self
986            .info_post(
987                serde_json::json!({"type": "l2Book", "coin": symbol}),
988                2,
989                "get_l2_orderbook",
990            )
991            .await?;
992        let book: Option<HyperliquidWsBook> = parse_response(resp).await?;
993        let book = match book {
994            Some(b) => b,
995            None => {
996                return Ok(L2Snapshot {
997                    symbol,
998                    bids: vec![],
999                    asks: vec![],
1000                    sequence: 0,
1001                })
1002            }
1003        };
1004        let bids = book
1005            .levels
1006            .first()
1007            .into_iter()
1008            .flatten()
1009            .filter_map(|level| {
1010                Some(L2Level {
1011                    price: parse_decimal(&level.px)?,
1012                    volume: parse_decimal(&level.sz)?,
1013                })
1014            })
1015            .collect();
1016        let asks = book
1017            .levels
1018            .get(1)
1019            .into_iter()
1020            .flatten()
1021            .filter_map(|level| {
1022                Some(L2Level {
1023                    price: parse_decimal(&level.px)?,
1024                    volume: parse_decimal(&level.sz)?,
1025                })
1026            })
1027            .collect();
1028        Ok(L2Snapshot {
1029            symbol: book.coin,
1030            bids,
1031            asks,
1032            sequence: book.time,
1033        })
1034    }
1035
1036    /// Returns the mid-price of `symbol` (e.g. "BTC") from allMids.
1037    async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
1038        // allMids → weight 2
1039        let resp = self
1040            .info_post(serde_json::json!({"type": "allMids"}), 2, "get_price")
1041            .await?;
1042        parse_response::<HashMap<String, String>>(resp)
1043            .await?
1044            .get(&symbol)
1045            .and_then(|s| parse_decimal(s))
1046            .ok_or_else(|| format!("symbol {} not found", symbol))
1047    }
1048
1049    /// Returns predicted funding rates for all symbols across all venues.
1050    /// Null venue entries (unsupported coins) are silently skipped.
1051    async fn get_predicted_fundings(&self) -> Result<Vec<PredictedFunding>, String> {
1052        // predictedFundings → weight 20
1053        let resp = self
1054            .info_post(
1055                serde_json::json!({"type": "predictedFundings"}),
1056                20,
1057                "get_predicted_fundings",
1058            )
1059            .await?;
1060        let data: PredictedFundingsResponse = parse_response(resp).await?;
1061        let mut result = Vec::new();
1062        for (symbol, venues) in data {
1063            for (venue, entry) in venues {
1064                let Some(entry) = entry else { continue };
1065                if let Some(funding_rate) = parse_decimal(&entry.funding_rate) {
1066                    result.push(PredictedFunding {
1067                        symbol: symbol.clone(),
1068                        venue,
1069                        funding_rate,
1070                        next_funding_time_ms: entry.next_funding_time,
1071                    });
1072                }
1073            }
1074        }
1075        Ok(result)
1076    }
1077}
1078
1079#[async_trait]
1080impl guilder_abstraction::ManageOrder for HyperliquidClient {
1081    /// Places an order on Hyperliquid. Requires `with_auth`. Returns an `OrderPlacement` with
1082    /// the exchange-assigned order ID. Market orders are submitted as aggressive limit orders (IOC).
1083    ///
1084    /// If `cloid` is provided, Hyperliquid attaches it to the order lifecycle — fills and order
1085    /// updates will carry the same cloid back, enabling end-to-end intent tracing without a
1086    /// separate order_id mapping.
1087    ///
1088    /// Trigger orders (`TakeProfit` / `StopLoss`) require `trigger_price` to be set. The order
1089    /// activates when the mark price reaches `triggerPx`, then executes as a market or limit
1090    /// order depending on `time_in_force` (`Ioc` = market, `Gtc` = limit).
1091    async fn place_order(
1092        &self,
1093        symbol: String,
1094        side: OrderSide,
1095        price: Decimal,
1096        volume: Decimal,
1097        order_type: OrderType,
1098        time_in_force: TimeInForce,
1099        trigger_price: Option<Decimal>,
1100        reduce_only: bool,
1101        cloid: Option<String>,
1102    ) -> Result<OrderPlacement, String> {
1103        // Rate limiting is handled in submit_signed_action (non-blocking).
1104        let asset_idx = self.get_asset_index(&symbol).await?;
1105        let is_buy = matches!(side, OrderSide::Buy);
1106
1107        let tif_str = match time_in_force {
1108            TimeInForce::Gtc => "Gtc",
1109            TimeInForce::Ioc => "Ioc",
1110            TimeInForce::Fok => "Fok",
1111            TimeInForce::Alo => "Alo",
1112        };
1113
1114        let cloid_hex = cloid.clone();
1115
1116        // Determine if this is a trigger order
1117        let is_trigger = matches!(order_type, OrderType::TakeProfit | OrderType::StopLoss);
1118
1119        let (order_msgpack, order_type_json) = if is_trigger {
1120            // --- Trigger order ---
1121            let trigger_px = trigger_price
1122                .ok_or_else(|| format!("{:?} order requires trigger_price to be set", order_type))?
1123                .normalize()
1124                .to_string();
1125
1126            let tpsl = match order_type {
1127                OrderType::TakeProfit => "tp",
1128                OrderType::StopLoss => "sl",
1129                _ => unreachable!(),
1130            };
1131
1132            // TimeInForce determines market vs limit on trigger:
1133            // Ioc = market execution (isMarket: true), Gtc/Alo/Fok = limit (isMarket: false)
1134            let is_market = matches!(time_in_force, TimeInForce::Ioc);
1135
1136            // For trigger orders, `p` must be set to the trigger price (not "0"),
1137            // even for market-on-trigger. Hyperliquid validates this field.
1138            let price_str = if is_market {
1139                trigger_px.clone()
1140            } else {
1141                price.normalize().to_string()
1142            };
1143
1144            let msgpack = build_trigger_order_msgpack(
1145                asset_idx,
1146                is_buy,
1147                &price_str,
1148                &volume.normalize().to_string(),
1149                reduce_only,
1150                &trigger_px,
1151                is_market,
1152                tpsl,
1153                cloid_hex.as_deref(),
1154            );
1155
1156            let json_type = if is_market {
1157                format!(
1158                    r#"{{"trigger":{{"isMarket":true,"triggerPx":"{}","tpsl":"{}"}}}}"#,
1159                    trigger_px, tpsl
1160                )
1161            } else {
1162                format!(
1163                    r#"{{"trigger":{{"isMarket":false,"triggerPx":"{}","tpsl":"{}"}}}}"#,
1164                    trigger_px, tpsl
1165                )
1166            };
1167
1168            (msgpack, json_type)
1169        } else {
1170            // --- Regular limit/market order ---
1171            let (order_kind, tif_bytes) = match order_type {
1172                OrderType::Limit => ("limit", tif_str.as_bytes()),
1173                OrderType::Market => ("limit", b"Ioc".as_slice()),
1174                _ => unreachable!(),
1175            };
1176
1177            let price_str = price.normalize().to_string();
1178            let size_str = volume.normalize().to_string();
1179
1180            let msgpack = build_order_msgpack(
1181                asset_idx,
1182                is_buy,
1183                &price_str,
1184                &size_str,
1185                reduce_only,
1186                order_kind,
1187                tif_bytes,
1188                cloid_hex.as_deref(),
1189            );
1190
1191            let json_type = match order_type {
1192                OrderType::Limit => format!(r#"{{"limit":{{"tif":"{tif_str}"}}}}"#),
1193                OrderType::Market => r#"{"limit":{"tif":"Ioc"}}"#.to_string(),
1194                _ => unreachable!(),
1195            };
1196
1197            (msgpack, json_type)
1198        };
1199
1200        // Build the action-level msgpack with Python SDK field order (insertion order):
1201        // type → orders → grouping
1202        let mut action_msgpack = Vec::new();
1203        action_msgpack.push(0x83); // fixmap(3)
1204        action_msgpack.extend_from_slice(&value_to_msgpack(&Value::String("type".to_string())));
1205        action_msgpack.extend_from_slice(&value_to_msgpack(&Value::String("order".to_string())));
1206        action_msgpack.extend_from_slice(&value_to_msgpack(&Value::String("orders".to_string())));
1207        action_msgpack.push(0x91); // fixarray(1)
1208        action_msgpack.extend_from_slice(&order_msgpack);
1209        action_msgpack.extend_from_slice(&value_to_msgpack(&Value::String("grouping".to_string())));
1210        action_msgpack.extend_from_slice(&value_to_msgpack(&Value::String("na".to_string())));
1211
1212        let cloid_json = if let Some(ref c) = cloid_hex {
1213            format!(r#","c":"{c}""#)
1214        } else {
1215            String::new()
1216        };
1217
1218        let reduce_json = if reduce_only { "true" } else { "false" };
1219
1220        let price_for_json = if is_trigger && matches!(time_in_force, TimeInForce::Ioc) {
1221            // For market-on-trigger, use trigger price (same as msgpack)
1222            if let Some(ref tp) = trigger_price {
1223                tp.normalize().to_string()
1224            } else {
1225                price.normalize().to_string()
1226            }
1227        } else {
1228            price.normalize().to_string()
1229        };
1230
1231        let action_json_str = format!(
1232            r#"{{"type":"order","orders":[{{"a":{asset_idx},"b":{is_buy},"p":"{price}","s":"{size}","r":{reduce_json},"t":{order_type_json}{cloid_json}}}],"grouping":"na"}}"#,
1233            price = price_for_json,
1234            size = volume.normalize().to_string(),
1235        );
1236
1237        // Sign using the canonical msgpack (matching Python's field order)
1238        let private_key = self.require_private_key()?;
1239        let nonce = std::time::SystemTime::now()
1240            .duration_since(std::time::UNIX_EPOCH)
1241            .unwrap()
1242            .as_millis() as u64;
1243
1244        let (r, s, v) = sign_with_msgpack(
1245            &action_msgpack,
1246            private_key,
1247            self.external_signer.as_ref(),
1248            nonce,
1249            None,
1250            self.network.eip712_source(),
1251        )
1252        .await?;
1253
1254        let payload_str = format!(
1255            r#"{{"action":{},"nonce":{},"signature":{{"r":"{}","s":"{}","v":{}}},"vaultAddress":null,"expiresAfter":null}}"#,
1256            action_json_str, nonce, r, s, v
1257        );
1258
1259        self.rest_limiter.acquire(1).await.map_err(|e| {
1260            format!(
1261                "rate_limited: rest_weight exhausted, retry_after_ms={}",
1262                e.retry_after.as_millis()
1263            )
1264        })?;
1265        self.address_limiter.acquire(1, false).await.map_err(|e| {
1266            format!(
1267                "rate_limited: address quota exhausted, retry_after_ms={}",
1268                e.retry_after.as_millis()
1269            )
1270        })?;
1271
1272        let resp = self
1273            .client
1274            .post(self.network.exchange_url())
1275            .header("Content-Type", "application/json")
1276            .body(payload_str)
1277            .send()
1278            .await
1279            .map_err(|e| e.to_string())?;
1280
1281        let status = resp.status();
1282        if !status.is_success() {
1283            let text = resp.text().await.map_err(|e| e.to_string())?;
1284            return Err(format!("HTTP {status}: {text}"));
1285        }
1286
1287        let body: Value = parse_response(resp).await?;
1288        if body["status"].as_str() == Some("err") {
1289            return Err(body["response"]
1290                .as_str()
1291                .unwrap_or("unknown error")
1292                .to_string());
1293        }
1294        let statuses = &body["response"]["data"]["statuses"][0];
1295
1296        let (oid, returned_cloid, timestamp_ms) = if let Some(resting) = statuses.get("resting") {
1297            let oid = resting["oid"]
1298                .as_i64()
1299                .ok_or_else(|| format!("resting status missing oid: {}", body))?;
1300            let returned_cloid = resting["cloid"].as_str().map(|s: &str| s.to_string());
1301            // resting doesn't include a timestamp
1302            let ts = std::time::SystemTime::now()
1303                .duration_since(std::time::UNIX_EPOCH)
1304                .unwrap()
1305                .as_millis() as i64;
1306            (oid, returned_cloid, ts)
1307        } else if let Some(filled) = statuses.get("filled") {
1308            let oid = filled["oid"]
1309                .as_i64()
1310                .ok_or_else(|| format!("filled status missing oid: {}", body))?;
1311            // filled doesn't include cloid
1312            let ts = std::time::SystemTime::now()
1313                .duration_since(std::time::UNIX_EPOCH)
1314                .unwrap()
1315                .as_millis() as i64;
1316            (oid, None, ts)
1317        } else if let Some(error) = statuses.get("error") {
1318            return Err(error
1319                .as_str()
1320                .unwrap_or("order rejected with unknown error")
1321                .to_string());
1322        } else {
1323            return Err(format!("unexpected order status: {}", body));
1324        };
1325
1326        Ok(OrderPlacement {
1327            order_id: oid,
1328            symbol,
1329            side,
1330            price,
1331            quantity: volume,
1332            timestamp_ms,
1333            cloid: returned_cloid.or(cloid),
1334            order_type,
1335            trigger_price,
1336            reduce_only,
1337        })
1338    }
1339
1340    /// Modifies price and size of an existing order by its order ID. Requires `with_auth`.
1341    /// Fetches the order's current coin and side before submitting the modify action.
1342    async fn change_order_by_cloid(
1343        &self,
1344        cloid: i64,
1345        price: Decimal,
1346        volume: Decimal,
1347    ) -> Result<i64, String> {
1348        let user = self.require_user_address()?;
1349
1350        // openOrders → weight 20; get_asset_index → meta weight 20
1351        let resp = self
1352            .info_post(
1353                serde_json::json!({"type": "openOrders", "user": user}),
1354                20,
1355                "change_order_by_cloid",
1356            )
1357            .await?;
1358        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
1359        let order = orders
1360            .iter()
1361            .find(|o| o.oid == cloid)
1362            .ok_or_else(|| format!("order {} not found", cloid))?;
1363
1364        let asset_idx = self.get_asset_index(&order.coin).await?;
1365        let is_buy = order.side == "B";
1366
1367        let action = serde_json::json!({
1368            "type": "batchModify",
1369            "modifies": [{
1370                "oid": cloid,
1371                "order": {
1372                    "a": asset_idx,
1373                    "b": is_buy,
1374                    "p": price.to_string(),
1375                    "s": volume.to_string(),
1376                    "r": false,
1377                    "t": {"limit": {"tif": "Gtc"}}
1378                }
1379            }]
1380        });
1381
1382        self.submit_signed_action(action, None).await?;
1383        Ok(cloid)
1384    }
1385
1386    /// Cancels a single order by its client order ID (cloid). Requires `with_auth`.
1387    /// Fetches open orders to resolve the order ID for the matching cloid, then
1388    /// submits a cancel action using the order ID — this works for all order types
1389    /// including trigger orders (TakeProfit/StopLoss).
1390    async fn cancel_order_by_cloid(&self, cloid: String) -> Result<(), String> {
1391        let user = self.require_user_address()?;
1392
1393        // openOrders → weight 20
1394        let resp = self
1395            .info_post(
1396                serde_json::json!({"type": "openOrders", "user": user}),
1397                20,
1398                "cancel_order_by_cloid",
1399            )
1400            .await?;
1401        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
1402        let order = orders
1403            .iter()
1404            .find(|o| o.cloid.as_ref() == Some(&cloid))
1405            .ok_or_else(|| format!("order with cloid {} not found", cloid))?;
1406
1407        // meta → weight 20
1408        let meta_resp = self
1409            .info_post(
1410                serde_json::json!({"type": "meta"}),
1411                20,
1412                "cancel_order_by_cloid",
1413            )
1414            .await?;
1415        let meta: MetaResponse = parse_response(meta_resp).await?;
1416
1417        let asset_idx = meta
1418            .universe
1419            .iter()
1420            .position(|a| a.name == order.coin)
1421            .ok_or_else(|| format!("asset {} not found in meta", order.coin))?;
1422
1423        // Use the same "cancel" action type as cancel_all_order, which is
1424        // proven to work for all order types including trigger orders.
1425        let action = serde_json::json!({
1426            "type": "cancel",
1427            "cancels": [{"a": asset_idx, "o": order.oid}]
1428        });
1429
1430        self.submit_signed_action(action, None).await?;
1431        Ok(())
1432    }
1433
1434    /// Cancels all open orders. Requires `with_auth`.
1435    /// Fetches all open orders and submits a batch cancel in a single signed request.
1436    async fn cancel_all_order(&self) -> Result<bool, String> {
1437        let user = self.require_user_address()?;
1438
1439        // openOrders → weight 20
1440        let resp = self
1441            .info_post(
1442                serde_json::json!({"type": "openOrders", "user": user}),
1443                20,
1444                "cancel_all_order",
1445            )
1446            .await?;
1447        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
1448        if orders.is_empty() {
1449            return Ok(true);
1450        }
1451
1452        // meta → weight 20
1453        let meta_resp = self
1454            .info_post(serde_json::json!({"type": "meta"}), 20, "cancel_all_order")
1455            .await?;
1456        let meta: MetaResponse = parse_response(meta_resp).await?;
1457
1458        let cancels: Vec<Value> = orders
1459            .iter()
1460            .filter_map(|o| {
1461                let asset_idx = meta.universe.iter().position(|a| a.name == o.coin)?;
1462                Some(serde_json::json!({"a": asset_idx, "o": o.oid}))
1463            })
1464            .collect();
1465
1466        let action = serde_json::json!({"type": "cancel", "cancels": cancels});
1467        self.submit_signed_action(action, None).await?;
1468        Ok(true)
1469    }
1470}
1471
1472#[async_trait]
1473impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
1474    fn subscribe_l2_update(&self, symbol: String) -> BoxStream<Result<L2Update, String>> {
1475        Box::pin(stream::iter(vec![Err(format!(
1476            "subscribe_l2_update is unsupported for {symbol}; use subscribe_l2_snapshot"
1477        ))]))
1478    }
1479
1480    fn subscribe_l2_snapshot(&self, symbol: String) -> BoxStream<Result<L2Snapshot, String>> {
1481        let subscription = HyperliquidSubscription::L2Book { coin: symbol };
1482        Box::pin(managed_stream(
1483            self.market_ws_manager.clone(),
1484            subscription,
1485            |msg: HyperliquidWsInboundMessage| {
1486                if let Some(snapshot) = msg.as_l2_snapshot() {
1487                    vec![Ok(snapshot)]
1488                } else {
1489                    vec![]
1490                }
1491            },
1492        ))
1493    }
1494
1495    fn subscribe_asset_context(&self, symbol: String) -> BoxStream<Result<AssetContext, String>> {
1496        let subscription = HyperliquidSubscription::ActiveAssetCtx { coin: symbol };
1497        Box::pin(managed_stream(
1498            self.market_ws_manager.clone(),
1499            subscription,
1500            |msg: HyperliquidWsInboundMessage| {
1501            if let Some(ctx) = msg.as_asset_context() {
1502                vec![Ok(ctx)]
1503            } else {
1504                vec![]
1505            }
1506        }))
1507    }
1508
1509    fn subscribe_liquidation(&self, user: String) -> BoxStream<Result<Liquidation, String>> {
1510        subscribe_user_stream(
1511            self,
1512            user.clone(),
1513            HyperliquidSubscription::UserEvents { user_addr: user },
1514            |msg: HyperliquidWsInboundMessage| {
1515                if let Some(liq) = msg.as_liquidation() {
1516                    vec![Ok(liq)]
1517                } else {
1518                    vec![]
1519                }
1520            },
1521        )
1522    }
1523
1524    fn subscribe_fill(&self, symbol: String) -> BoxStream<Result<Fill, String>> {
1525        let subscription = HyperliquidSubscription::Trades { coin: symbol };
1526        Box::pin(managed_stream(
1527            self.market_ws_manager.clone(),
1528            subscription,
1529            |msg: HyperliquidWsInboundMessage| {
1530            if let Some(fills) = msg.as_trades() {
1531                fills.into_iter().map(Ok).collect()
1532            } else {
1533                vec![]
1534            }
1535        }))
1536    }
1537
1538    /// Gracefully shut down all market data subscriptions.
1539    /// Closes all broadcast channels so subscribers exit without reconnecting.
1540    async fn unsubscribe_all(&self) {
1541        self.market_ws_manager.shutdown();
1542    }
1543}
1544
1545fn subscribe_user_stream<T, F>(
1546    client: &HyperliquidClient,
1547    user_addr: String,
1548    subscription: HyperliquidSubscription,
1549    parse: F,
1550) -> BoxStream<Result<T, String>>
1551where
1552    T: Send + 'static,
1553    F: Fn(HyperliquidWsInboundMessage) -> Vec<Result<T, String>> + Send + Sync + 'static,
1554{
1555    let manager = get_or_create_user_manager(
1556        &client.user_ws_managers,
1557        client.ws_send_limiter.clone(),
1558        user_addr,
1559        client.network.ws_url(),
1560    );
1561    Box::pin(async_stream::stream! {
1562        let stream = managed_stream(manager, subscription, parse);
1563        tokio::pin!(stream);
1564        while let Some(item) = stream.next().await {
1565            yield item;
1566        }
1567    })
1568}
1569
1570fn get_or_create_user_manager(
1571    user_ws_managers: &RwLock<HashMap<String, HyperliquidWsManager>>,
1572    ws_send_limiter: WsSendRateLimiter,
1573    user_addr: String,
1574    ws_url: &'static str,
1575) -> HyperliquidWsManager {
1576    {
1577        let managers = user_ws_managers.read().unwrap_or_else(|e| e.into_inner());
1578        if let Some(manager) = managers.get(&user_addr) {
1579            return manager.clone();
1580        }
1581    }
1582
1583    let mut managers = user_ws_managers.write().unwrap_or_else(|e| e.into_inner());
1584    managers
1585        .entry(user_addr.clone())
1586        .or_insert_with(|| HyperliquidWsManager::new(Some(user_addr), ws_send_limiter, ws_url))
1587        .clone()
1588}
1589
1590#[async_trait]
1591impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
1592    /// Returns open positions from `clearinghouseState`. Requires `with_auth`.
1593    /// Zero-size positions are filtered out. Positive `szi` = long, negative = short.
1594    async fn get_positions(&self) -> Result<Vec<Position>, String> {
1595        let user = self.require_user_address()?;
1596        // clearinghouseState → weight 2
1597        let resp = self
1598            .info_post(
1599                serde_json::json!({"type": "clearinghouseState", "user": user}),
1600                2,
1601                "get_positions",
1602            )
1603            .await?;
1604        let state: ClearinghouseStateResponse = parse_response(resp).await?;
1605
1606        Ok(state
1607            .asset_positions
1608            .into_iter()
1609            .filter_map(|ap| {
1610                let p = ap.position;
1611                let size = parse_decimal(&p.szi)?;
1612                if size.is_zero() {
1613                    return None;
1614                }
1615                let entry_price = p
1616                    .entry_px
1617                    .as_deref()
1618                    .and_then(parse_decimal)
1619                    .unwrap_or_default();
1620                let side = if size > Decimal::ZERO {
1621                    OrderSide::Buy
1622                } else {
1623                    OrderSide::Sell
1624                };
1625                Some(Position {
1626                    symbol: p.coin,
1627                    side,
1628                    size: size.abs(),
1629                    entry_price,
1630                })
1631            })
1632            .collect())
1633    }
1634
1635    /// Returns resting orders from Hyperliquid's `openOrders` endpoint. Requires `with_auth`.
1636    /// `filled_quantity` is derived as `origSz - sz` (original size minus remaining size).
1637    async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
1638        let user = self.require_user_address()?;
1639        // openOrders → weight 20
1640        let resp = self
1641            .info_post(
1642                serde_json::json!({"type": "openOrders", "user": user}),
1643                20,
1644                "get_open_orders",
1645            )
1646            .await?;
1647        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
1648
1649        Ok(orders
1650            .into_iter()
1651            .filter_map(|o| {
1652                let price = parse_decimal(&o.limit_px)?;
1653                let quantity = parse_decimal(&o.orig_sz)?;
1654                let remaining = parse_decimal(&o.sz)?;
1655                let filled_quantity = quantity - remaining;
1656                let side = if o.side == "B" {
1657                    OrderSide::Buy
1658                } else {
1659                    OrderSide::Sell
1660                };
1661                Some(OpenOrder {
1662                    order_id: o.oid,
1663                    symbol: o.coin,
1664                    side,
1665                    price,
1666                    quantity,
1667                    filled_quantity,
1668                    order_type: None, // openOrders REST endpoint doesn't return order type
1669                    trigger_price: None, // trigger info not included in openOrders response
1670                    reduce_only: false, // default; REST doesn't expose this field
1671                })
1672            })
1673            .collect())
1674    }
1675
1676    /// Returns all per-asset balances from `spotClearinghouseState` with margin health.
1677    /// Also fetches `clearinghouseState` to populate `margin_used` on the USDC entry.
1678    /// Requires `with_auth`.
1679    async fn get_balance(&self) -> Result<Vec<guilder_abstraction::AccountBalance>, String> {
1680        let user = self.require_user_address()?;
1681        // spotClearinghouseState → weight 15
1682        let resp = self
1683            .info_post(
1684                serde_json::json!({"type": "spotClearinghouseState", "user": user}),
1685                15,
1686                "get_balance",
1687            )
1688            .await?;
1689
1690        #[derive(Deserialize)]
1691        struct SpotStateResponse {
1692            balances: Vec<SpotBalance>,
1693            #[serde(rename = "tokenToAvailableAfterMaintenance")]
1694            token_to_available_after_maintenance: Vec<(i32, String)>,
1695        }
1696
1697        #[allow(dead_code)]
1698        #[derive(Deserialize)]
1699        struct SpotBalance {
1700            coin: String,
1701            total: String,
1702            hold: String,
1703            #[serde(default)]
1704            token: Option<i32>,
1705            #[serde(default)]
1706            #[serde(rename = "entryNtl")]
1707            entry_ntl: Option<String>,
1708        }
1709
1710        let state: SpotStateResponse = parse_response(resp).await?;
1711
1712        // Build a safe lookup map from token ID → available after maintenance.
1713        // Not every token appears in the map — absence means zero maintenance impact.
1714        let safe_map: HashMap<i32, Decimal> = state
1715            .token_to_available_after_maintenance
1716            .into_iter()
1717            .filter_map(|(token_id, value)| {
1718                parse_decimal(&value).map(|v| (token_id, v))
1719            })
1720            .collect();
1721
1722        // Fetch perp-side margin summary for margin_used.
1723        // clearinghouseState → weight 2. Failure is non-fatal — margin_used
1724        // stays None for all entries.
1725        let perp_margin_used: Option<Decimal> = match self
1726            .info_post(
1727                serde_json::json!({"type": "clearinghouseState", "user": user}),
1728                2,
1729                "get_balance_margin",
1730            )
1731            .await
1732        {
1733            Ok(resp) => parse_response::<ClearinghouseStateResponse>(resp)
1734                .await
1735                .ok()
1736                .and_then(|ch| {
1737                    ch.margin_summary
1738                        .total_margin_used
1739                        .as_deref()
1740                        .and_then(parse_decimal)
1741                }),
1742            Err(_) => None,
1743        };
1744
1745        state
1746            .balances
1747            .into_iter()
1748            .map(|balance| {
1749                let equity = parse_decimal(&balance.total)
1750                    .ok_or_else(|| "invalid total balance".to_string())?;
1751                let hold = parse_decimal(&balance.hold)
1752                    .ok_or_else(|| "invalid hold balance".to_string())?;
1753                let free = equity - hold;
1754
1755                let safe = balance.token.and_then(|token_id| safe_map.get(&token_id).copied());
1756                let usable = match safe {
1757                    Some(s) => std::cmp::min(free, s),
1758                    None => free,
1759                };
1760                let maintenance = safe.map(|s| equity - s);
1761
1762                // margin_used is account-level (perp side); only populate on USDC.
1763                let margin_used = if balance.coin == "USDC" {
1764                    perp_margin_used
1765                } else {
1766                    None
1767                };
1768
1769                Ok(guilder_abstraction::AccountBalance {
1770                    token: balance.coin,
1771                    equity,
1772                    free,
1773                    safe,
1774                    usable,
1775                    hold,
1776                    margin_used,
1777                    maintenance,
1778                })
1779            })
1780            .collect()
1781    }
1782
1783    /// Returns the user's address-level API rate limit budget.
1784    /// Queries Hyperliquid's `userRateLimit` info endpoint for authoritative server-side counts.
1785    async fn get_user_rate_limit(&self) -> Result<guilder_abstraction::UserRateLimit, String> {
1786        let user = self.require_user_address()?;
1787        let resp = self
1788            .info_post(
1789                serde_json::json!({"type": "userRateLimit", "user": user}),
1790                20,
1791                "get_user_rate_limit",
1792            )
1793            .await?;
1794        let val = parse_response::<Value>(resp).await?;
1795
1796        let cumulative_volume = val["cumVlm"]
1797            .as_str()
1798            .and_then(parse_decimal)
1799            .ok_or_else(|| "missing or invalid cumVlm".to_string())?;
1800        let requests_used = val["nRequestsUsed"]
1801            .as_i64()
1802            .ok_or_else(|| "missing or invalid nRequestsUsed".to_string())?;
1803        let requests_cap = val["nRequestsCap"]
1804            .as_i64()
1805            .ok_or_else(|| "missing or invalid nRequestsCap".to_string())?;
1806        let requests_surplus = val["nRequestsSurplus"]
1807            .as_i64()
1808            .ok_or_else(|| "missing or invalid nRequestsSurplus".to_string())?;
1809
1810        Ok(guilder_abstraction::UserRateLimit {
1811            cumulative_volume,
1812            requests_used,
1813            requests_cap,
1814            requests_surplus,
1815        })
1816    }
1817}
1818
1819#[async_trait]
1820impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
1821    fn subscribe_user_fills(&self) -> BoxStream<Result<UserFill, String>> {
1822        let Some(addr) = self.user_address.as_ref() else {
1823            return Box::pin(stream::iter(vec![Err(
1824                "user address not registered".to_string()
1825            )]));
1826        };
1827        subscribe_user_stream(
1828            self,
1829            addr.clone(),
1830            HyperliquidSubscription::UserEvents {
1831                user_addr: addr.clone(),
1832            },
1833            |msg: HyperliquidWsInboundMessage| {
1834                if let Some(fills) = msg.as_user_fills() {
1835                    fills.into_iter().map(Ok).collect()
1836                } else {
1837                    vec![]
1838                }
1839            },
1840        )
1841    }
1842
1843    fn subscribe_order_updates(&self) -> BoxStream<Result<OrderUpdate, String>> {
1844        let Some(addr) = self.user_address.as_ref() else {
1845            return Box::pin(stream::iter(vec![Err(
1846                "user address not registered".to_string()
1847            )]));
1848        };
1849        subscribe_user_stream(
1850            self,
1851            addr.clone(),
1852            HyperliquidSubscription::OrderUpdates {
1853                user_addr: addr.clone(),
1854            },
1855            |msg: HyperliquidWsInboundMessage| {
1856                if let Some(updates) = msg.as_order_updates() {
1857                    updates.into_iter().map(Ok).collect()
1858                } else {
1859                    vec![]
1860                }
1861            },
1862        )
1863    }
1864
1865    fn subscribe_funding_payments(&self) -> BoxStream<Result<FundingPayment, String>> {
1866        let Some(addr) = self.user_address.as_ref() else {
1867            return Box::pin(stream::iter(vec![Err(
1868                "user address not registered".to_string()
1869            )]));
1870        };
1871        subscribe_user_stream(
1872            self,
1873            addr.clone(),
1874            HyperliquidSubscription::UserEvents {
1875                user_addr: addr.clone(),
1876            },
1877            |msg: HyperliquidWsInboundMessage| {
1878                if let Some(p) = msg.as_funding_payment() {
1879                    vec![Ok(p)]
1880                } else {
1881                    vec![]
1882                }
1883            },
1884        )
1885    }
1886
1887    fn subscribe_deposits(&self) -> BoxStream<Result<Deposit, String>> {
1888        let Some(addr) = self.user_address.as_ref() else {
1889            return Box::pin(stream::iter(vec![Err(
1890                "user address not registered".to_string()
1891            )]));
1892        };
1893        subscribe_user_stream(
1894            self,
1895            addr.clone(),
1896            HyperliquidSubscription::NonFundingLedger {
1897                user_addr: addr.clone(),
1898            },
1899            |msg: HyperliquidWsInboundMessage| {
1900                if let Some(deps) = msg.as_deposits() {
1901                    deps.into_iter().map(Ok).collect()
1902                } else {
1903                    vec![]
1904                }
1905            },
1906        )
1907    }
1908
1909    fn subscribe_withdrawals(&self) -> BoxStream<Result<Withdrawal, String>> {
1910        let Some(addr) = self.user_address.as_ref() else {
1911            return Box::pin(stream::iter(vec![Err(
1912                "user address not registered".to_string()
1913            )]));
1914        };
1915        subscribe_user_stream(
1916            self,
1917            addr.clone(),
1918            HyperliquidSubscription::NonFundingLedger {
1919                user_addr: addr.clone(),
1920            },
1921            |msg: HyperliquidWsInboundMessage| {
1922                if let Some(wds) = msg.as_withdrawals() {
1923                    wds.into_iter().map(Ok).collect()
1924                } else {
1925                    vec![]
1926                }
1927            },
1928        )
1929    }
1930
1931    /// Subscribe to spot wallet balance updates for the registered user address.
1932    fn subscribe_spot_balance(
1933        &self,
1934    ) -> BoxStream<Result<Vec<guilder_abstraction::AccountBalance>, String>> {
1935        let Some(addr) = self.user_address.as_ref() else {
1936            return Box::pin(stream::iter(vec![Err(
1937                "user address not registered".to_string()
1938            )]));
1939        };
1940        self.subscribe_spot_balance_with_address(addr.clone())
1941    }
1942
1943    /// Subscribe to spot wallet balance updates for a specific address.
1944    fn subscribe_spot_balance_with_address(
1945        &self,
1946        address: String,
1947    ) -> BoxStream<Result<Vec<guilder_abstraction::AccountBalance>, String>> {
1948        subscribe_user_stream(
1949            self,
1950            address.clone(),
1951            HyperliquidSubscription::UserEvents { user_addr: address },
1952            |msg: HyperliquidWsInboundMessage| {
1953                if let Some(balances) = msg.as_spot_balance() {
1954                    vec![Ok(balances)]
1955                } else {
1956                    vec![]
1957                }
1958            },
1959        )
1960    }
1961
1962    async fn unsubscribe_user_events(&self) {
1963        // Unsubscribe from the market-level user manager if we have a user address.
1964        if let Some(addr) = &self.user_address {
1965            self.market_ws_manager.unsubscribe_user(addr);
1966        }
1967        // Also unsubscribe from all per-user managers.
1968        let managers = self.user_ws_managers.read().unwrap_or_else(|e| e.into_inner());
1969        for (addr, manager) in managers.iter() {
1970            manager.unsubscribe_user(addr);
1971        }
1972    }
1973}
1974
1975#[async_trait]
1976impl guilder_abstraction::SubscribeMarketDataOps for HyperliquidClient {
1977    async fn unsubscribe_market_data(&self, symbol: String) {
1978        self.market_ws_manager.unsubscribe_by_coin(&symbol);
1979    }
1980}
1981
1982#[cfg(test)]
1983mod msgpack_tests {
1984    use super::*;
1985    use serde_json::json;
1986
1987    #[test]
1988    fn test_eip712_digest_known_answers() {
1989        // Reference digests generated with the official hyperliquid-python-sdk
1990        // (msgpack.packb → action_hash → EIP-712 Agent payload → eth_account
1991        // sign + ECDSA address-recovery verified). Regen script logic:
1992        // nonce + action mirrored exactly below.
1993        // 0.6.4 regression guard: source was hardcoded to "a" (mainnet), which
1994        // makes every TESTNET signature verify against the wrong phantom agent
1995        // ("User or API Wallet 0x... does not exist").
1996        let action = json!({
1997            "type": "order",
1998            "orders": [{
1999                "a": 1, "b": true, "p": "1500.5", "s": "0.05",
2000                "r": false, "t": {"limit": {"tif": "Gtc"}}
2001            }],
2002            "grouping": "na"
2003        });
2004        let nonce: u64 = 1758572400123;
2005        let msgpack = action_to_canonical_msgpack(&action).unwrap();
2006
2007        let mainnet = compute_eip712_digest(&msgpack, nonce, None, "a").unwrap();
2008        assert_eq!(
2009            hex::encode(mainnet),
2010            "28cd97dd515629633af463bd7edaab14e61b3941b638d410c317cac7e0aed860"
2011        );
2012
2013        let testnet = compute_eip712_digest(&msgpack, nonce, None, "b").unwrap();
2014        assert_eq!(
2015            hex::encode(testnet),
2016            "d452e53f806773ca6d7ab5d10147518bff0d8b64a4404dcb062e3d2fb3d85c0b"
2017        );
2018
2019        assert_ne!(mainnet, testnet);
2020        assert_eq!(HyperliquidNetwork::Mainnet.eip712_source(), "a");
2021        assert_eq!(HyperliquidNetwork::Testnet.eip712_source(), "b");
2022    }
2023
2024    #[test]
2025    fn test_msgpack_null() {
2026        let result = value_to_msgpack(&Value::Null);
2027        assert_eq!(result, vec![0xc0]);
2028    }
2029
2030    #[test]
2031    fn test_msgpack_bool() {
2032        assert_eq!(value_to_msgpack(&Value::Bool(true)), vec![0xc3]);
2033        assert_eq!(value_to_msgpack(&Value::Bool(false)), vec![0xc2]);
2034    }
2035
2036    #[test]
2037    fn test_msgpack_positive_fixint() {
2038        // 0–127: positive fixint
2039        assert_eq!(value_to_msgpack(&json!(0)), vec![0x00]);
2040        assert_eq!(value_to_msgpack(&json!(1)), vec![0x01]);
2041        assert_eq!(value_to_msgpack(&json!(127)), vec![0x7f]);
2042    }
2043
2044    #[test]
2045    fn test_msgpack_uint8() {
2046        // 128–255: uint8
2047        assert_eq!(value_to_msgpack(&json!(128)), vec![0xcc, 0x80]);
2048        assert_eq!(value_to_msgpack(&json!(255)), vec![0xcc, 0xff]);
2049    }
2050
2051    #[test]
2052    fn test_msgpack_uint16() {
2053        // 256–65535: uint16
2054        assert_eq!(value_to_msgpack(&json!(256)), vec![0xcd, 0x01, 0x00]);
2055        assert_eq!(value_to_msgpack(&json!(65535)), vec![0xcd, 0xff, 0xff]);
2056    }
2057
2058    #[test]
2059    fn test_msgpack_uint32() {
2060        // 65536–4294967295: uint32
2061        assert_eq!(
2062            value_to_msgpack(&json!(65536)),
2063            vec![0xce, 0x00, 0x01, 0x00, 0x00]
2064        );
2065        assert_eq!(
2066            value_to_msgpack(&json!(4294967295u64)),
2067            vec![0xce, 0xff, 0xff, 0xff, 0xff]
2068        );
2069    }
2070
2071    #[test]
2072    fn test_msgpack_uint64() {
2073        // >4294967295: uint64
2074        let big: u64 = 4294967296;
2075        assert_eq!(
2076            value_to_msgpack(&json!(big)),
2077            vec![0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]
2078        );
2079    }
2080
2081    #[test]
2082    fn test_msgpack_negative_fixint() {
2083        // -1 to -32: negative fixint
2084        assert_eq!(value_to_msgpack(&json!(-1)), vec![0xff]);
2085        assert_eq!(value_to_msgpack(&json!(-32)), vec![0xe0]);
2086    }
2087
2088    #[test]
2089    fn test_msgpack_int8() {
2090        // -33 to -128: int8
2091        assert_eq!(value_to_msgpack(&json!(-33)), vec![0xd0, 0xdf]);
2092        assert_eq!(value_to_msgpack(&json!(-128)), vec![0xd0, 0x80]);
2093    }
2094
2095    #[test]
2096    fn test_msgpack_int16() {
2097        // -129 to -32768: int16
2098        assert_eq!(value_to_msgpack(&json!(-129)), vec![0xd1, 0xff, 0x7f]);
2099        assert_eq!(value_to_msgpack(&json!(-32768)), vec![0xd1, 0x80, 0x00]);
2100    }
2101
2102    #[test]
2103    fn test_msgpack_int32() {
2104        // -32769 to -2147483648: int32
2105        assert_eq!(
2106            value_to_msgpack(&json!(-32769)),
2107            vec![0xd2, 0xff, 0xff, 0x7f, 0xff]
2108        );
2109        assert_eq!(
2110            value_to_msgpack(&json!(-2147483648i64)),
2111            vec![0xd2, 0x80, 0x00, 0x00, 0x00]
2112        );
2113    }
2114
2115    #[test]
2116    fn test_msgpack_int64() {
2117        let val: i64 = -2147483649;
2118        let result = value_to_msgpack(&json!(val));
2119        assert_eq!(result[0], 0xd3); // int64 marker
2120        assert_eq!(result.len(), 9);
2121    }
2122
2123    #[test]
2124    fn test_msgpack_float() {
2125        let result = value_to_msgpack(&json!(3.14));
2126        assert_eq!(result[0], 0xcb); // float64 marker
2127        assert_eq!(result.len(), 9);
2128    }
2129
2130    #[test]
2131    fn test_msgpack_fixstr() {
2132        // 0–31 bytes: fixstr
2133        assert_eq!(value_to_msgpack(&json!("")), vec![0xa0]);
2134        assert_eq!(value_to_msgpack(&json!("hello")), {
2135            let mut expected = vec![0xa5];
2136            expected.extend_from_slice(b"hello");
2137            expected
2138        });
2139        let s = "a".repeat(31);
2140        let result = value_to_msgpack(&json!(s));
2141        assert_eq!(result[0], 0xbf); // 0xa0 | 31
2142        assert_eq!(result.len(), 32);
2143    }
2144
2145    #[test]
2146    fn test_msgpack_str8() {
2147        let s = "a".repeat(32);
2148        let result = value_to_msgpack(&json!(s));
2149        assert_eq!(result[0], 0xd9); // str8 marker
2150        assert_eq!(result[1], 32);
2151        assert_eq!(result.len(), 34);
2152    }
2153
2154    #[test]
2155    fn test_msgpack_fixarray() {
2156        // 0–15 elements: fixarray
2157        assert_eq!(value_to_msgpack(&json!([])), vec![0x90]);
2158        let result = value_to_msgpack(&json!([1, 2, 3]));
2159        assert_eq!(result[0], 0x93);
2160        assert_eq!(result, vec![0x93, 0x01, 0x02, 0x03]);
2161    }
2162
2163    #[test]
2164    fn test_msgpack_fixmap() {
2165        // 0–15 entries: fixmap
2166        assert_eq!(value_to_msgpack(&json!({})), vec![0x80]);
2167        let result = value_to_msgpack(&json!({"a": 1}));
2168        assert_eq!(result[0], 0x81); // fixmap(1)
2169        assert_eq!(result, {
2170            let mut expected = vec![0x81];
2171            expected.extend_from_slice(&value_to_msgpack(&json!("a")));
2172            expected.extend_from_slice(&value_to_msgpack(&json!(1)));
2173            expected
2174        });
2175    }
2176
2177    #[test]
2178    fn test_msgmap_preserves_insertion_order() {
2179        // Verify keys are serialized in JSON insertion order, not sorted
2180        let val = json!({
2181            "z": 1,
2182            "a": 2,
2183            "m": 3
2184        });
2185        let result = value_to_msgpack(&val);
2186        // fixmap(3)
2187        assert_eq!(result[0], 0x83);
2188        // First key should be "z" (insertion order), not "a" (sorted)
2189        assert_eq!(result[1], 0xa1); // fixstr(1)
2190        assert_eq!(result[2], b'z');
2191    }
2192
2193    #[test]
2194    fn test_msgpack_mixed_array() {
2195        let val = json!([null, true, false, 42, "hi", [1, 2]]);
2196        let result = value_to_msgpack(&val);
2197        assert_eq!(result[0], 0x96); // fixarray(6)
2198        assert_eq!(result[1], 0xc0); // null
2199        assert_eq!(result[2], 0xc3); // true
2200        assert_eq!(result[3], 0xc2); // false
2201        assert_eq!(result[4], 0x2a); // 42
2202                                     // "hi" = fixstr(2) + "hi"
2203        assert_eq!(result[5], 0xa2);
2204        assert_eq!(result[6], b'h');
2205        assert_eq!(result[7], b'i');
2206    }
2207
2208    #[test]
2209    fn test_build_order_msgpack_without_cloid() {
2210        let result = build_order_msgpack(
2211            0,       // asset index
2212            true,    // is_buy
2213            "1000",  // price
2214            "0.1",   // size
2215            false,   // reduce_only
2216            "limit", // order_kind
2217            b"gtc",  // tif
2218            None,    // cloid
2219        );
2220        // fixmap(6)
2221        assert_eq!(result[0], 0x86);
2222    }
2223
2224    #[test]
2225    fn test_build_order_msgpack_with_cloid() {
2226        let result = build_order_msgpack(
2227            0,                // asset index
2228            true,             // is_buy
2229            "1000",           // price
2230            "0.1",            // size
2231            false,            // reduce_only
2232            "limit",          // order_kind
2233            b"gtc",           // tif
2234            Some("my-cloid"), // cloid
2235        );
2236        // fixmap(7)
2237        assert_eq!(result[0], 0x87);
2238    }
2239
2240    #[test]
2241    fn test_action_to_canonical_msgpack() {
2242        let action = json!({
2243            "type": "order",
2244            "orders": [{"a": 0, "b": true, "p": "1000", "s": "0.1", "r": false, "t": {"limit": {"tif": "gtc"}}}],
2245            "grouping": "na"
2246        });
2247        let result = action_to_canonical_msgpack(&action).unwrap();
2248        // fixmap(3)
2249        assert_eq!(result[0], 0x83);
2250    }
2251
2252    #[test]
2253    fn test_msgpack_matches_rmp_serde_for_simple_values() {
2254        // Verify our encoding matches rmp_serde for simple scalar values
2255        use rmp_serde::to_vec;
2256
2257        for val in [
2258            json!(0),
2259            json!(127),
2260            json!(255),
2261            json!(1000),
2262            json!(-1),
2263            json!(-32),
2264            json!(-128),
2265        ] {
2266            let ours = value_to_msgpack(&val);
2267            let theirs = to_vec(&val).unwrap();
2268            assert_eq!(
2269                ours, theirs,
2270                "mismatch for {}: ours={:?}, rmp={:?}",
2271                val, ours, theirs
2272            );
2273        }
2274    }
2275
2276    #[test]
2277    fn test_msgpack_string_encoding() {
2278        use rmp_serde::to_vec;
2279        for val in [
2280            json!(""),
2281            json!("a"),
2282            json!("hello world"),
2283            json!("BTC-USD"),
2284        ] {
2285            let ours = value_to_msgpack(&val);
2286            let theirs = to_vec(&val).unwrap();
2287            assert_eq!(
2288                ours, theirs,
2289                "mismatch for {}: ours={:?}, rmp={:?}",
2290                val, ours, theirs
2291            );
2292        }
2293    }
2294
2295    #[test]
2296    fn test_msgpack_bool_encoding() {
2297        use rmp_serde::to_vec;
2298        let theirs = to_vec(&json!(true)).unwrap();
2299        assert_eq!(value_to_msgpack(&json!(true)), theirs);
2300        let theirs = to_vec(&json!(false)).unwrap();
2301        assert_eq!(value_to_msgpack(&json!(false)), theirs);
2302    }
2303
2304    #[test]
2305    fn test_msgpack_null_encoding() {
2306        use rmp_serde::to_vec;
2307        let theirs = to_vec(&Value::Null).unwrap();
2308        assert_eq!(value_to_msgpack(&Value::Null), theirs);
2309    }
2310
2311    #[test]
2312    fn test_msgpack_nested_object() {
2313        let val = json!({
2314            "outer": {
2315                "inner": 42
2316            }
2317        });
2318        let result = value_to_msgpack(&val);
2319        assert_eq!(result[0], 0x81); // fixmap(1)
2320    }
2321
2322    #[test]
2323    fn test_msgpack_empty_containers() {
2324        assert_eq!(value_to_msgpack(&json!([])), vec![0x90]);
2325        assert_eq!(value_to_msgpack(&json!({})), vec![0x80]);
2326    }
2327}
2328
2329
2330#[async_trait::async_trait]
2331impl guilder_abstraction::ListingEventSource for HyperliquidClient {
2332    /// Authoritative lifecycle events from the venue. Hyperliquid's info API
2333    /// exposes only the CURRENT universe (+ isDelisted flags) — it has no
2334    /// historical listing stream, so this returns one synthetic `list` event
2335    /// per never-delisted symbol (event_time unknown → epoch placeholder) and
2336    /// a `delist` event per isDelisted symbol. The QDB sync layer upserts
2337    /// these into token_registry_events; true FIRST-LISTING times for
2338    /// pre-history coins come from earlier sync runs, not from this call.
2339    async fn get_listing_events(
2340        &self,
2341    ) -> Result<Vec<guilder_abstraction::ListingEvent>, String> {
2342        // meta → weight 20
2343        let resp = self
2344            .info_post(serde_json::json!({"type": "meta"}), 20, "get_listing_events")
2345            .await?;
2346        let meta = parse_response::<MetaResponse>(resp).await?;
2347        Ok(meta
2348            .universe
2349            .into_iter()
2350            .map(|a| guilder_abstraction::ListingEvent {
2351                ticker: a.name,
2352                exchange: "hyperliquid_perp".to_string(),
2353                event: if a.is_delisted { "delist" } else { "list" }.to_string(),
2354                event_time: String::new(),
2355            })
2356            .collect())
2357    }
2358
2359    async fn get_current_universe(
2360        &self,
2361    ) -> Result<Vec<guilder_abstraction::SymbolStatus>, String> {
2362        // meta → weight 20
2363        let resp = self
2364            .info_post(serde_json::json!({"type": "meta"}), 20, "get_current_universe")
2365            .await?;
2366        let meta = parse_response::<MetaResponse>(resp).await?;
2367        Ok(meta
2368            .universe
2369            .into_iter()
2370            .map(|a| guilder_abstraction::SymbolStatus {
2371                ticker: a.name,
2372                exchange: "hyperliquid_perp".to_string(),
2373                is_delisted: a.is_delisted,
2374            })
2375            .collect())
2376    }
2377}