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