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