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