Skip to main content

guilder_client_hyperliquid/
client.rs

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