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