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