Skip to main content

guilder_client_hyperliquid/
client.rs

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