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