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                let entry_price = p
1747                    .entry_px
1748                    .as_deref()
1749                    .and_then(parse_decimal)
1750                    .unwrap_or_default();
1751                let side = if size > Decimal::ZERO {
1752                    OrderSide::Buy
1753                } else {
1754                    OrderSide::Sell
1755                };
1756                Some(Position {
1757                    symbol: p.coin,
1758                    side,
1759                    size: size.abs(),
1760                    entry_price,
1761                })
1762            })
1763            .collect())
1764    }
1765
1766    /// Returns resting orders from Hyperliquid's `openOrders` endpoint. Requires `with_auth`.
1767    /// `filled_quantity` is derived as `origSz - sz` (original size minus remaining size).
1768    async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
1769        let user = self.require_user_address()?;
1770        // openOrders → weight 20
1771        let resp = self
1772            .info_post(
1773                serde_json::json!({"type": "openOrders", "user": user}),
1774                20,
1775                "get_open_orders",
1776            )
1777            .await?;
1778        let orders: Vec<RestOpenOrder> = parse_response(resp).await?;
1779
1780        Ok(orders
1781            .into_iter()
1782            .filter_map(|o| {
1783                let price = parse_decimal(&o.limit_px)?;
1784                let quantity = parse_decimal(&o.orig_sz)?;
1785                let remaining = parse_decimal(&o.sz)?;
1786                let filled_quantity = quantity - remaining;
1787                let side = if o.side == "B" {
1788                    OrderSide::Buy
1789                } else {
1790                    OrderSide::Sell
1791                };
1792                Some(OpenOrder {
1793                    order_id: o.oid,
1794                    symbol: o.coin,
1795                    side,
1796                    price,
1797                    quantity,
1798                    filled_quantity,
1799                    order_type: None, // openOrders REST endpoint doesn't return order type
1800                    trigger_price: None, // trigger info not included in openOrders response
1801                    reduce_only: false, // default; REST doesn't expose this field
1802                })
1803            })
1804            .collect())
1805    }
1806
1807    /// Returns all per-asset balances from `spotClearinghouseState` with margin health.
1808    /// Also fetches `clearinghouseState` to populate `margin_used` on the USDC entry.
1809    /// Requires `with_auth`.
1810    async fn get_balance(&self) -> Result<Vec<guilder_abstraction::AccountBalance>, String> {
1811        let user = self.require_user_address()?;
1812        // spotClearinghouseState → weight 15
1813        let resp = self
1814            .info_post(
1815                serde_json::json!({"type": "spotClearinghouseState", "user": user}),
1816                15,
1817                "get_balance",
1818            )
1819            .await?;
1820
1821        #[allow(dead_code)]
1822        #[derive(Deserialize)]
1823        struct SpotBalance {
1824            coin: String,
1825            total: String,
1826            hold: String,
1827            #[serde(default)]
1828            token: Option<i32>,
1829            #[serde(default)]
1830            #[serde(rename = "entryNtl")]
1831            entry_ntl: Option<String>,
1832        }
1833
1834        let state: SpotStateResponse = parse_response(resp).await?;
1835
1836        // Fetch the PERP ledger (clearinghouseState). weight 2.
1837        let perp_parsed: Option<ClearinghouseStateResponse> = match self
1838            .info_post(
1839                serde_json::json!({"type": "clearinghouseState", "user": user}),
1840                2,
1841                "get_balance_margin",
1842            )
1843            .await
1844        {
1845            Ok(resp) => parse_response::<ClearinghouseStateResponse>(resp).await.ok(),
1846            Err(_) => None,
1847        };
1848
1849        // Perp-side margin_used lands on the SPOT USDC row for backwards
1850        // compatibility (0.6.x consumers).
1851        let perp_margin_used: Option<Decimal> = perp_parsed.as_ref().and_then(|ch| {
1852            ch.margin_summary
1853                .total_margin_used
1854                .as_deref()
1855                .and_then(parse_decimal)
1856        });
1857
1858        // Spot ledger rows — keep per-asset spot balances (incl. USDC spot
1859        // cash, which is NOT margin under the manual-account model).
1860        let mut rows = map_spot_state(state, perp_margin_used)?;
1861
1862        // Perp ledger row appended LAST (stable position for consumers that
1863        // index rows): token = PERP_LEDGER_TOKEN. equity = accountValue,
1864        // free/usable = accountValue − totalMarginUsed, margin fields filled.
1865        // Failure here is fatal — a trader that cannot read its futures
1866        // ledger must not fall back to spot sizing.
1867        let perp_state: ClearinghouseStateResponse = match perp_parsed {
1868            Some(ch) => ch,
1869            None => {
1870                // The margin probe failed — retry once so a transient
1871                // clearinghouse error cannot silently drop the futures row.
1872                let resp = self
1873                    .info_post(
1874                        serde_json::json!({"type": "clearinghouseState", "user": user}),
1875                        2,
1876                        "get_balance_perp_row",
1877                    )
1878                    .await?;
1879                parse_response::<ClearinghouseStateResponse>(resp).await?
1880            }
1881        };
1882        rows.push(map_perp_state(perp_state)?);
1883
1884        Ok(rows)
1885    }
1886
1887    /// Returns the user's address-level API rate limit budget.
1888    /// Queries Hyperliquid's `userRateLimit` info endpoint for authoritative server-side counts.
1889    async fn get_user_rate_limit(&self) -> Result<guilder_abstraction::UserRateLimit, String> {
1890        let user = self.require_user_address()?;
1891        let resp = self
1892            .info_post(
1893                serde_json::json!({"type": "userRateLimit", "user": user}),
1894                20,
1895                "get_user_rate_limit",
1896            )
1897            .await?;
1898        let val = parse_response::<Value>(resp).await?;
1899
1900        let cumulative_volume = val["cumVlm"]
1901            .as_str()
1902            .and_then(parse_decimal)
1903            .ok_or_else(|| "missing or invalid cumVlm".to_string())?;
1904        let requests_used = val["nRequestsUsed"]
1905            .as_i64()
1906            .ok_or_else(|| "missing or invalid nRequestsUsed".to_string())?;
1907        let requests_cap = val["nRequestsCap"]
1908            .as_i64()
1909            .ok_or_else(|| "missing or invalid nRequestsCap".to_string())?;
1910        let requests_surplus = val["nRequestsSurplus"]
1911            .as_i64()
1912            .ok_or_else(|| "missing or invalid nRequestsSurplus".to_string())?;
1913
1914        Ok(guilder_abstraction::UserRateLimit {
1915            cumulative_volume,
1916            requests_used,
1917            requests_cap,
1918            requests_surplus,
1919        })
1920    }
1921}
1922
1923#[async_trait]
1924impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
1925    fn subscribe_user_fills(&self) -> BoxStream<Result<UserFill, String>> {
1926        let Some(addr) = self.user_address.as_ref() else {
1927            return Box::pin(stream::iter(vec![Err(
1928                "user address not registered".to_string()
1929            )]));
1930        };
1931        subscribe_user_stream(
1932            self,
1933            addr.clone(),
1934            HyperliquidSubscription::UserEvents {
1935                user_addr: addr.clone(),
1936            },
1937            |msg: HyperliquidWsInboundMessage| {
1938                if let Some(fills) = msg.as_user_fills() {
1939                    fills.into_iter().map(Ok).collect()
1940                } else {
1941                    vec![]
1942                }
1943            },
1944        )
1945    }
1946
1947    fn subscribe_order_updates(&self) -> BoxStream<Result<OrderUpdate, String>> {
1948        let Some(addr) = self.user_address.as_ref() else {
1949            return Box::pin(stream::iter(vec![Err(
1950                "user address not registered".to_string()
1951            )]));
1952        };
1953        subscribe_user_stream(
1954            self,
1955            addr.clone(),
1956            HyperliquidSubscription::OrderUpdates {
1957                user_addr: addr.clone(),
1958            },
1959            |msg: HyperliquidWsInboundMessage| {
1960                if let Some(updates) = msg.as_order_updates() {
1961                    updates.into_iter().map(Ok).collect()
1962                } else {
1963                    vec![]
1964                }
1965            },
1966        )
1967    }
1968
1969    fn subscribe_funding_payments(&self) -> BoxStream<Result<FundingPayment, String>> {
1970        let Some(addr) = self.user_address.as_ref() else {
1971            return Box::pin(stream::iter(vec![Err(
1972                "user address not registered".to_string()
1973            )]));
1974        };
1975        subscribe_user_stream(
1976            self,
1977            addr.clone(),
1978            HyperliquidSubscription::UserEvents {
1979                user_addr: addr.clone(),
1980            },
1981            |msg: HyperliquidWsInboundMessage| {
1982                if let Some(p) = msg.as_funding_payment() {
1983                    vec![Ok(p)]
1984                } else {
1985                    vec![]
1986                }
1987            },
1988        )
1989    }
1990
1991    fn subscribe_deposits(&self) -> BoxStream<Result<Deposit, String>> {
1992        let Some(addr) = self.user_address.as_ref() else {
1993            return Box::pin(stream::iter(vec![Err(
1994                "user address not registered".to_string()
1995            )]));
1996        };
1997        subscribe_user_stream(
1998            self,
1999            addr.clone(),
2000            HyperliquidSubscription::NonFundingLedger {
2001                user_addr: addr.clone(),
2002            },
2003            |msg: HyperliquidWsInboundMessage| {
2004                if let Some(deps) = msg.as_deposits() {
2005                    deps.into_iter().map(Ok).collect()
2006                } else {
2007                    vec![]
2008                }
2009            },
2010        )
2011    }
2012
2013    fn subscribe_withdrawals(&self) -> BoxStream<Result<Withdrawal, String>> {
2014        let Some(addr) = self.user_address.as_ref() else {
2015            return Box::pin(stream::iter(vec![Err(
2016                "user address not registered".to_string()
2017            )]));
2018        };
2019        subscribe_user_stream(
2020            self,
2021            addr.clone(),
2022            HyperliquidSubscription::NonFundingLedger {
2023                user_addr: addr.clone(),
2024            },
2025            |msg: HyperliquidWsInboundMessage| {
2026                if let Some(wds) = msg.as_withdrawals() {
2027                    wds.into_iter().map(Ok).collect()
2028                } else {
2029                    vec![]
2030                }
2031            },
2032        )
2033    }
2034
2035    /// Subscribe to spot wallet balance updates for the registered user address.
2036    fn subscribe_spot_balance(
2037        &self,
2038    ) -> BoxStream<Result<Vec<guilder_abstraction::AccountBalance>, String>> {
2039        let Some(addr) = self.user_address.as_ref() else {
2040            return Box::pin(stream::iter(vec![Err(
2041                "user address not registered".to_string()
2042            )]));
2043        };
2044        self.subscribe_spot_balance_with_address(addr.clone())
2045    }
2046
2047    /// Subscribe to spot wallet balance updates for a specific address.
2048    fn subscribe_spot_balance_with_address(
2049        &self,
2050        address: String,
2051    ) -> BoxStream<Result<Vec<guilder_abstraction::AccountBalance>, String>> {
2052        subscribe_user_stream(
2053            self,
2054            address.clone(),
2055            HyperliquidSubscription::UserEvents { user_addr: address },
2056            |msg: HyperliquidWsInboundMessage| {
2057                if let Some(balances) = msg.as_spot_balance() {
2058                    vec![Ok(balances)]
2059                } else {
2060                    vec![]
2061                }
2062            },
2063        )
2064    }
2065
2066    async fn unsubscribe_user_events(&self) {
2067        // Unsubscribe from the market-level user manager if we have a user address.
2068        if let Some(addr) = &self.user_address {
2069            self.market_ws_manager.unsubscribe_user(addr);
2070        }
2071        // Also unsubscribe from all per-user managers.
2072        let managers = self
2073            .user_ws_managers
2074            .read()
2075            .unwrap_or_else(|e| e.into_inner());
2076        for (addr, manager) in managers.iter() {
2077            manager.unsubscribe_user(addr);
2078        }
2079    }
2080}
2081
2082#[async_trait]
2083impl guilder_abstraction::SubscribeMarketDataOps for HyperliquidClient {
2084    async fn unsubscribe_market_data(&self, symbol: String) {
2085        self.market_ws_manager.unsubscribe_by_coin(&symbol);
2086    }
2087}
2088
2089#[cfg(test)]
2090mod msgpack_tests {
2091    use super::*;
2092    use serde_json::json;
2093
2094    #[test]
2095    fn test_eip712_digest_known_answers() {
2096        // Reference digests generated with the official hyperliquid-python-sdk
2097        // (msgpack.packb → action_hash → EIP-712 Agent payload → eth_account
2098        // sign + ECDSA address-recovery verified). Regen script logic:
2099        // nonce + action mirrored exactly below.
2100        // 0.6.4 regression guard: source was hardcoded to "a" (mainnet), which
2101        // makes every TESTNET signature verify against the wrong phantom agent
2102        // ("User or API Wallet 0x... does not exist").
2103        let action = json!({
2104            "type": "order",
2105            "orders": [{
2106                "a": 1, "b": true, "p": "1500.5", "s": "0.05",
2107                "r": false, "t": {"limit": {"tif": "Gtc"}}
2108            }],
2109            "grouping": "na"
2110        });
2111        let nonce: u64 = 1758572400123;
2112        let msgpack = action_to_canonical_msgpack(&action).unwrap();
2113
2114        let mainnet = compute_eip712_digest(&msgpack, nonce, None, "a").unwrap();
2115        assert_eq!(
2116            hex::encode(mainnet),
2117            "28cd97dd515629633af463bd7edaab14e61b3941b638d410c317cac7e0aed860"
2118        );
2119
2120        let testnet = compute_eip712_digest(&msgpack, nonce, None, "b").unwrap();
2121        assert_eq!(
2122            hex::encode(testnet),
2123            "d452e53f806773ca6d7ab5d10147518bff0d8b64a4404dcb062e3d2fb3d85c0b"
2124        );
2125
2126        assert_ne!(mainnet, testnet);
2127        assert_eq!(HyperliquidNetwork::Mainnet.eip712_source(), "a");
2128        assert_eq!(HyperliquidNetwork::Testnet.eip712_source(), "b");
2129    }
2130
2131    #[test]
2132    fn test_msgpack_null() {
2133        let result = value_to_msgpack(&Value::Null);
2134        assert_eq!(result, vec![0xc0]);
2135    }
2136
2137    #[test]
2138    fn test_msgpack_bool() {
2139        assert_eq!(value_to_msgpack(&Value::Bool(true)), vec![0xc3]);
2140        assert_eq!(value_to_msgpack(&Value::Bool(false)), vec![0xc2]);
2141    }
2142
2143    #[test]
2144    fn test_msgpack_positive_fixint() {
2145        // 0–127: positive fixint
2146        assert_eq!(value_to_msgpack(&json!(0)), vec![0x00]);
2147        assert_eq!(value_to_msgpack(&json!(1)), vec![0x01]);
2148        assert_eq!(value_to_msgpack(&json!(127)), vec![0x7f]);
2149    }
2150
2151    #[test]
2152    fn test_msgpack_uint8() {
2153        // 128–255: uint8
2154        assert_eq!(value_to_msgpack(&json!(128)), vec![0xcc, 0x80]);
2155        assert_eq!(value_to_msgpack(&json!(255)), vec![0xcc, 0xff]);
2156    }
2157
2158    #[test]
2159    fn test_msgpack_uint16() {
2160        // 256–65535: uint16
2161        assert_eq!(value_to_msgpack(&json!(256)), vec![0xcd, 0x01, 0x00]);
2162        assert_eq!(value_to_msgpack(&json!(65535)), vec![0xcd, 0xff, 0xff]);
2163    }
2164
2165    #[test]
2166    fn test_msgpack_uint32() {
2167        // 65536–4294967295: uint32
2168        assert_eq!(
2169            value_to_msgpack(&json!(65536)),
2170            vec![0xce, 0x00, 0x01, 0x00, 0x00]
2171        );
2172        assert_eq!(
2173            value_to_msgpack(&json!(4294967295u64)),
2174            vec![0xce, 0xff, 0xff, 0xff, 0xff]
2175        );
2176    }
2177
2178    #[test]
2179    fn test_msgpack_uint64() {
2180        // >4294967295: uint64
2181        let big: u64 = 4294967296;
2182        assert_eq!(
2183            value_to_msgpack(&json!(big)),
2184            vec![0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]
2185        );
2186    }
2187
2188    #[test]
2189    fn test_msgpack_negative_fixint() {
2190        // -1 to -32: negative fixint
2191        assert_eq!(value_to_msgpack(&json!(-1)), vec![0xff]);
2192        assert_eq!(value_to_msgpack(&json!(-32)), vec![0xe0]);
2193    }
2194
2195    #[test]
2196    fn test_msgpack_int8() {
2197        // -33 to -128: int8
2198        assert_eq!(value_to_msgpack(&json!(-33)), vec![0xd0, 0xdf]);
2199        assert_eq!(value_to_msgpack(&json!(-128)), vec![0xd0, 0x80]);
2200    }
2201
2202    #[test]
2203    fn test_msgpack_int16() {
2204        // -129 to -32768: int16
2205        assert_eq!(value_to_msgpack(&json!(-129)), vec![0xd1, 0xff, 0x7f]);
2206        assert_eq!(value_to_msgpack(&json!(-32768)), vec![0xd1, 0x80, 0x00]);
2207    }
2208
2209    #[test]
2210    fn test_msgpack_int32() {
2211        // -32769 to -2147483648: int32
2212        assert_eq!(
2213            value_to_msgpack(&json!(-32769)),
2214            vec![0xd2, 0xff, 0xff, 0x7f, 0xff]
2215        );
2216        assert_eq!(
2217            value_to_msgpack(&json!(-2147483648i64)),
2218            vec![0xd2, 0x80, 0x00, 0x00, 0x00]
2219        );
2220    }
2221
2222    #[test]
2223    fn test_msgpack_int64() {
2224        let val: i64 = -2147483649;
2225        let result = value_to_msgpack(&json!(val));
2226        assert_eq!(result[0], 0xd3); // int64 marker
2227        assert_eq!(result.len(), 9);
2228    }
2229
2230    #[test]
2231    fn test_msgpack_float() {
2232        let result = value_to_msgpack(&json!(3.14));
2233        assert_eq!(result[0], 0xcb); // float64 marker
2234        assert_eq!(result.len(), 9);
2235    }
2236
2237    #[test]
2238    fn test_msgpack_fixstr() {
2239        // 0–31 bytes: fixstr
2240        assert_eq!(value_to_msgpack(&json!("")), vec![0xa0]);
2241        assert_eq!(value_to_msgpack(&json!("hello")), {
2242            let mut expected = vec![0xa5];
2243            expected.extend_from_slice(b"hello");
2244            expected
2245        });
2246        let s = "a".repeat(31);
2247        let result = value_to_msgpack(&json!(s));
2248        assert_eq!(result[0], 0xbf); // 0xa0 | 31
2249        assert_eq!(result.len(), 32);
2250    }
2251
2252    #[test]
2253    fn test_msgpack_str8() {
2254        let s = "a".repeat(32);
2255        let result = value_to_msgpack(&json!(s));
2256        assert_eq!(result[0], 0xd9); // str8 marker
2257        assert_eq!(result[1], 32);
2258        assert_eq!(result.len(), 34);
2259    }
2260
2261    #[test]
2262    fn test_msgpack_fixarray() {
2263        // 0–15 elements: fixarray
2264        assert_eq!(value_to_msgpack(&json!([])), vec![0x90]);
2265        let result = value_to_msgpack(&json!([1, 2, 3]));
2266        assert_eq!(result[0], 0x93);
2267        assert_eq!(result, vec![0x93, 0x01, 0x02, 0x03]);
2268    }
2269
2270    #[test]
2271    fn test_msgpack_fixmap() {
2272        // 0–15 entries: fixmap
2273        assert_eq!(value_to_msgpack(&json!({})), vec![0x80]);
2274        let result = value_to_msgpack(&json!({"a": 1}));
2275        assert_eq!(result[0], 0x81); // fixmap(1)
2276        assert_eq!(result, {
2277            let mut expected = vec![0x81];
2278            expected.extend_from_slice(&value_to_msgpack(&json!("a")));
2279            expected.extend_from_slice(&value_to_msgpack(&json!(1)));
2280            expected
2281        });
2282    }
2283
2284    #[test]
2285    fn test_msgmap_preserves_insertion_order() {
2286        // Verify keys are serialized in JSON insertion order, not sorted
2287        let val = json!({
2288            "z": 1,
2289            "a": 2,
2290            "m": 3
2291        });
2292        let result = value_to_msgpack(&val);
2293        // fixmap(3)
2294        assert_eq!(result[0], 0x83);
2295        // First key should be "z" (insertion order), not "a" (sorted)
2296        assert_eq!(result[1], 0xa1); // fixstr(1)
2297        assert_eq!(result[2], b'z');
2298    }
2299
2300    #[test]
2301    fn test_msgpack_mixed_array() {
2302        let val = json!([null, true, false, 42, "hi", [1, 2]]);
2303        let result = value_to_msgpack(&val);
2304        assert_eq!(result[0], 0x96); // fixarray(6)
2305        assert_eq!(result[1], 0xc0); // null
2306        assert_eq!(result[2], 0xc3); // true
2307        assert_eq!(result[3], 0xc2); // false
2308        assert_eq!(result[4], 0x2a); // 42
2309                                     // "hi" = fixstr(2) + "hi"
2310        assert_eq!(result[5], 0xa2);
2311        assert_eq!(result[6], b'h');
2312        assert_eq!(result[7], b'i');
2313    }
2314
2315    #[test]
2316    fn test_build_order_msgpack_without_cloid() {
2317        let result = build_order_msgpack(
2318            0,       // asset index
2319            true,    // is_buy
2320            "1000",  // price
2321            "0.1",   // size
2322            false,   // reduce_only
2323            "limit", // order_kind
2324            b"gtc",  // tif
2325            None,    // cloid
2326        );
2327        // fixmap(6)
2328        assert_eq!(result[0], 0x86);
2329    }
2330
2331    #[test]
2332    fn test_build_order_msgpack_with_cloid() {
2333        let result = build_order_msgpack(
2334            0,                // asset index
2335            true,             // is_buy
2336            "1000",           // price
2337            "0.1",            // size
2338            false,            // reduce_only
2339            "limit",          // order_kind
2340            b"gtc",           // tif
2341            Some("my-cloid"), // cloid
2342        );
2343        // fixmap(7)
2344        assert_eq!(result[0], 0x87);
2345    }
2346
2347    #[test]
2348    fn test_action_to_canonical_msgpack() {
2349        let action = json!({
2350            "type": "order",
2351            "orders": [{"a": 0, "b": true, "p": "1000", "s": "0.1", "r": false, "t": {"limit": {"tif": "gtc"}}}],
2352            "grouping": "na"
2353        });
2354        let result = action_to_canonical_msgpack(&action).unwrap();
2355        // fixmap(3)
2356        assert_eq!(result[0], 0x83);
2357    }
2358
2359    #[test]
2360    fn test_msgpack_matches_rmp_serde_for_simple_values() {
2361        // Verify our encoding matches rmp_serde for simple scalar values
2362        use rmp_serde::to_vec;
2363
2364        for val in [
2365            json!(0),
2366            json!(127),
2367            json!(255),
2368            json!(1000),
2369            json!(-1),
2370            json!(-32),
2371            json!(-128),
2372        ] {
2373            let ours = value_to_msgpack(&val);
2374            let theirs = to_vec(&val).unwrap();
2375            assert_eq!(
2376                ours, theirs,
2377                "mismatch for {}: ours={:?}, rmp={:?}",
2378                val, ours, theirs
2379            );
2380        }
2381    }
2382
2383    #[test]
2384    fn test_msgpack_string_encoding() {
2385        use rmp_serde::to_vec;
2386        for val in [
2387            json!(""),
2388            json!("a"),
2389            json!("hello world"),
2390            json!("BTC-USD"),
2391        ] {
2392            let ours = value_to_msgpack(&val);
2393            let theirs = to_vec(&val).unwrap();
2394            assert_eq!(
2395                ours, theirs,
2396                "mismatch for {}: ours={:?}, rmp={:?}",
2397                val, ours, theirs
2398            );
2399        }
2400    }
2401
2402    #[test]
2403    fn test_msgpack_bool_encoding() {
2404        use rmp_serde::to_vec;
2405        let theirs = to_vec(&json!(true)).unwrap();
2406        assert_eq!(value_to_msgpack(&json!(true)), theirs);
2407        let theirs = to_vec(&json!(false)).unwrap();
2408        assert_eq!(value_to_msgpack(&json!(false)), theirs);
2409    }
2410
2411    #[test]
2412    fn test_msgpack_null_encoding() {
2413        use rmp_serde::to_vec;
2414        let theirs = to_vec(&Value::Null).unwrap();
2415        assert_eq!(value_to_msgpack(&Value::Null), theirs);
2416    }
2417
2418    #[test]
2419    fn test_msgpack_nested_object() {
2420        let val = json!({
2421            "outer": {
2422                "inner": 42
2423            }
2424        });
2425        let result = value_to_msgpack(&val);
2426        assert_eq!(result[0], 0x81); // fixmap(1)
2427    }
2428
2429    #[test]
2430    fn test_msgpack_empty_containers() {
2431        assert_eq!(value_to_msgpack(&json!([])), vec![0x90]);
2432        assert_eq!(value_to_msgpack(&json!({})), vec![0x80]);
2433    }
2434}
2435
2436#[async_trait::async_trait]
2437impl guilder_abstraction::ListingEventSource for HyperliquidClient {
2438    /// Authoritative lifecycle events from the venue. Hyperliquid's info API
2439    /// exposes only the CURRENT universe (+ isDelisted flags) — it has no
2440    /// historical listing stream, so this returns one synthetic `list` event
2441    /// per never-delisted symbol (event_time unknown → epoch placeholder) and
2442    /// a `delist` event per isDelisted symbol. The QDB sync layer upserts
2443    /// these into token_registry_events; true FIRST-LISTING times for
2444    /// pre-history coins come from earlier sync runs, not from this call.
2445    async fn get_listing_events(&self) -> Result<Vec<guilder_abstraction::ListingEvent>, String> {
2446        // meta → weight 20
2447        let resp = self
2448            .info_post(
2449                serde_json::json!({"type": "meta"}),
2450                20,
2451                "get_listing_events",
2452            )
2453            .await?;
2454        let meta = parse_response::<MetaResponse>(resp).await?;
2455        Ok(meta
2456            .universe
2457            .into_iter()
2458            .map(|a| guilder_abstraction::ListingEvent {
2459                ticker: a.name,
2460                exchange: "hyperliquid_perp".to_string(),
2461                event: if a.is_delisted { "delist" } else { "list" }.to_string(),
2462                event_time: String::new(),
2463            })
2464            .collect())
2465    }
2466
2467    async fn get_current_universe(&self) -> Result<Vec<guilder_abstraction::SymbolStatus>, String> {
2468        // meta → weight 20
2469        let resp = self
2470            .info_post(
2471                serde_json::json!({"type": "meta"}),
2472                20,
2473                "get_current_universe",
2474            )
2475            .await?;
2476        let meta = parse_response::<MetaResponse>(resp).await?;
2477        Ok(meta
2478            .universe
2479            .into_iter()
2480            .map(|a| guilder_abstraction::SymbolStatus {
2481                ticker: a.name,
2482                exchange: "hyperliquid_perp".to_string(),
2483                is_delisted: a.is_delisted,
2484            })
2485            .collect())
2486    }
2487}
2488
2489#[cfg(test)]
2490mod spot_state_tests {
2491    use super::*;
2492
2493    /// EXACT response shape Hyperliquid TESTNET returns for
2494    /// spotClearinghouseState — NO tokenToAvailableAfterMaintenance.
2495    /// (Captured live from trader.daometric.com's deserialize warning,
2496    /// 2026-09-26.) Must parse: USDC total 969, free 969, usable 969.
2497    const TESTNET_SHAPE: &str =
2498        r#"{"balances":[{"coin":"USDC","token":0,"total":"969.0","hold":"0.0","entryNtl":"0.0"}]}"#;
2499
2500    /// MAINNET shape — maintenance map present.
2501    const MAINNET_SHAPE: &str = r#"{"balances":[{"coin":"USDC","token":0,"total":"969.0","hold":"10.0","entryNtl":"0.0"}],"tokenToAvailableAfterMaintenance":[[0,"955.0"]]}"#;
2502
2503    #[test]
2504    fn testnet_spot_state_without_maintenance_map_parses() {
2505        let state: SpotStateResponse =
2506            serde_json::from_str(TESTNET_SHAPE).expect("testnet shape must deserialize");
2507        let balances = map_spot_state(state, None).expect("mapping must succeed");
2508        assert_eq!(balances.len(), 1);
2509        let usdc = &balances[0];
2510        assert_eq!(usdc.token, "USDC");
2511        assert_eq!(usdc.equity.to_string(), "969.0");
2512        assert_eq!(usdc.hold.to_string(), "0.0");
2513        assert_eq!(usdc.free.to_string(), "969.0");
2514        // No maintenance map → usable falls back to free.
2515        assert_eq!(usdc.usable.to_string(), "969.0");
2516        assert_eq!(usdc.safe, None);
2517        assert_eq!(usdc.maintenance, None);
2518    }
2519
2520    #[test]
2521    fn mainnet_spot_state_with_maintenance_map_parses() {
2522        let state: SpotStateResponse =
2523            serde_json::from_str(MAINNET_SHAPE).expect("mainnet shape must deserialize");
2524        let balances = map_spot_state(state, None).expect("mapping must succeed");
2525        let usdc = &balances[0];
2526        assert_eq!(usdc.equity.to_string(), "969.0");
2527        assert_eq!(usdc.free.to_string(), "959.0");
2528        // maintenance map present → usable = min(free, safe) = 955.
2529        assert_eq!(usdc.usable.to_string(), "955.0");
2530        assert_eq!(usdc.safe.map(|d| d.to_string()), Some("955.0".to_string()));
2531        assert_eq!(
2532            usdc.maintenance.map(|d| d.to_string()),
2533            Some("14.0".to_string())
2534        );
2535    }
2536
2537    #[test]
2538    fn usdc_balance_carries_perp_margin_used() {
2539        let state: SpotStateResponse =
2540            serde_json::from_str(TESTNET_SHAPE).expect("testnet shape must deserialize");
2541        let balances = map_spot_state(state, Some(Decimal::from(2))).expect("mapping must succeed");
2542        let usdc = &balances[0];
2543        assert_eq!(
2544            usdc.margin_used.map(|d| d.to_string()),
2545            Some("2".to_string())
2546        );
2547    }
2548
2549    /// 0.7.0 contract: `map_balance_rows` (spot + perp) appends the futures
2550    /// ledger row LAST with the marker token. Locked by the trader's
2551    /// ledger-split consumer (account manager).
2552    #[test]
2553    fn balance_rows_end_with_marked_perp_row() {
2554        let state: SpotStateResponse =
2555            serde_json::from_str(TESTNET_SHAPE).expect("testnet shape must deserialize");
2556        let perp: ClearinghouseStateResponse = serde_json::from_str(
2557            r#"{"marginSummary":{"accountValue":"30.0","totalMarginUsed":"5.0"},"assetPositions":[]}"#,
2558        )
2559        .expect("perp shape must deserialize");
2560        let mut rows =
2561            map_spot_state(state, Some(Decimal::from(2))).expect("spot rows must map");
2562        rows.push(map_perp_state(perp).expect("perp row must map"));
2563
2564        assert_eq!(rows.len(), 2);
2565        assert_eq!(rows[0].token, "USDC");
2566        assert_eq!(rows[0].margin_used.map(|d| d.to_string()), Some("2".to_string()));
2567        assert_eq!(rows[1].token, crate::PERP_LEDGER_TOKEN);
2568        assert_eq!(rows[1].equity.to_string(), "30.0");
2569        assert_eq!(rows[1].free.to_string(), "25.0");
2570        assert_eq!(rows[1].usable.to_string(), "25.0");
2571        assert_eq!(
2572            rows[1].margin_used.map(|d| d.to_string()),
2573            Some("5.0".to_string())
2574        );
2575        // 0.7.2: settled cash surface — fixture has no totalRawUsd → None
2576        assert_eq!(rows[1].settled_usd, None);
2577        // Marker token must never collide with a real asset symbol.
2578        assert!(rows[..rows.len() - 1]
2579            .iter()
2580            .all(|b| b.token != crate::PERP_LEDGER_TOKEN));
2581    }
2582
2583    /// PERP margin account (clearinghouseState) — the trading money under
2584    /// Hyperliquid's manual-account model (spot and futures are SEPARATE
2585    /// ledgers; SMR trades perps, so sizing reads THIS account).
2586    #[test]
2587    fn perp_margin_account_maps_to_usdc_row() {
2588        let state: ClearinghouseStateResponse = serde_json::from_str(
2589            r#"{"marginSummary":{"accountValue":"30.0","totalNtlPos":"0.0","totalRawUsd":"30.0","totalMarginUsed":"0.0"},"assetPositions":[]}"#,
2590        )
2591        .expect("perp shape must deserialize");
2592        let usdc = map_perp_state(state).expect("perp mapping must succeed");
2593        // 0.7.0: the perp ledger row is marked, not "USDC" — spot and futures
2594        // are separate ledgers and consumers split rows by token.
2595        assert_eq!(usdc.token, crate::PERP_LEDGER_TOKEN);
2596        // 0.7.2: settled_usd carries totalRawUsd (venue settled cash) when present.
2597        assert_eq!(usdc.settled_usd.map(|d| d.to_string()), Some("30.0".into()));
2598        assert_eq!(usdc.equity.to_string(), "30.0"); // accountValue
2599        assert_eq!(usdc.free.to_string(), "30.0"); // accountValue - marginUsed
2600        assert_eq!(usdc.usable.to_string(), "30.0");
2601        assert_eq!(
2602            usdc.margin_used.map(|d| d.to_string()),
2603            Some("0.0".to_string())
2604        );
2605    }
2606}