Skip to main content

guilder_client_hyperliquid/
client.rs

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