Skip to main content

hyperliquid_sdk/
client.rs

1//! Main SDK client for Hyperliquid.
2//!
3//! Provides a unified interface for all Hyperliquid operations.
4
5use alloy::primitives::Address;
6use alloy::signers::local::PrivateKeySigner;
7use dashmap::DashMap;
8use parking_lot::RwLock;
9use reqwest::Client;
10use rust_decimal::Decimal;
11use serde_json::{json, Value};
12use std::collections::HashMap;
13use std::str::FromStr;
14use std::sync::Arc;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use crate::error::{Error, Result};
18use crate::order::{Order, PlacedOrder, TriggerOrder};
19use crate::signing::sign_hash;
20use crate::types::*;
21
22// ══════════════════════════════════════════════════════════════════════════════
23// Constants
24// ══════════════════════════════════════════════════════════════════════════════
25
26const DEFAULT_WORKER_URL: &str = "https://send.hyperliquidapi.com";
27const DEFAULT_WORKER_INFO_URL: &str = "https://send.hyperliquidapi.com/info";
28
29/// Known path segments that are NOT tokens
30const KNOWN_PATHS: &[&str] = &["info", "hypercore", "evm", "nanoreth", "ws", "send"];
31const HL_INFO_URL: &str = "https://api.hyperliquid.xyz/info";
32#[allow(dead_code)]
33const HL_EXCHANGE_URL: &str = "https://api.hyperliquid.xyz/exchange";
34const DEFAULT_SLIPPAGE: f64 = 0.03; // 3%
35const DEFAULT_TIMEOUT_SECS: u64 = 30;
36const METADATA_CACHE_TTL_SECS: u64 = 300; // 5 minutes
37
38// QuickNode-supported info query types
39const QN_SUPPORTED_INFO_TYPES: &[&str] = &[
40    "meta",
41    "spotMeta",
42    "clearinghouseState",
43    "spotClearinghouseState",
44    "openOrders",
45    "exchangeStatus",
46    "frontendOpenOrders",
47    "liquidatable",
48    "activeAssetData",
49    "maxMarketOrderNtls",
50    "vaultSummaries",
51    "userVaultEquities",
52    "leadingVaults",
53    "extraAgents",
54    "subAccounts",
55    "userFees",
56    "userRateLimit",
57    "spotDeployState",
58    "perpDeployAuctionStatus",
59    "delegations",
60    "delegatorSummary",
61    "maxBuilderFee",
62    "userToMultiSigSigners",
63    "userRole",
64    "perpsAtOpenInterestCap",
65    "validatorL1Votes",
66    "marginTable",
67    "perpDexs",
68    "webData2",
69];
70
71// ══════════════════════════════════════════════════════════════════════════════
72// Asset Metadata
73// ══════════════════════════════════════════════════════════════════════════════
74
75/// Asset metadata information
76#[derive(Debug, Clone)]
77pub struct AssetInfo {
78    pub index: usize,
79    pub name: String,
80    pub sz_decimals: u8,
81    pub is_spot: bool,
82}
83
84/// Metadata cache
85#[derive(Debug, Default)]
86pub struct MetadataCache {
87    assets: RwLock<HashMap<String, AssetInfo>>,
88    assets_by_index: RwLock<HashMap<usize, AssetInfo>>,
89    dexes: RwLock<Vec<String>>,
90    last_update: RwLock<Option<SystemTime>>,
91}
92
93impl MetadataCache {
94    /// Get asset info by name
95    pub fn get_asset(&self, name: &str) -> Option<AssetInfo> {
96        self.assets.read().get(name).cloned()
97    }
98
99    /// Get asset info by index
100    pub fn get_asset_by_index(&self, index: usize) -> Option<AssetInfo> {
101        self.assets_by_index.read().get(&index).cloned()
102    }
103
104    /// Resolve asset name to index
105    pub fn resolve_asset(&self, name: &str) -> Option<usize> {
106        self.assets.read().get(name).map(|a| a.index)
107    }
108
109    /// Get all DEX names
110    pub fn get_dexes(&self) -> Vec<String> {
111        self.dexes.read().clone()
112    }
113
114    /// Check if cache is valid
115    pub fn is_valid(&self) -> bool {
116        if let Some(last) = *self.last_update.read() {
117            if let Ok(elapsed) = last.elapsed() {
118                return elapsed.as_secs() < METADATA_CACHE_TTL_SECS;
119            }
120        }
121        false
122    }
123
124    /// Update cache from API response
125    pub fn update(&self, meta: &Value, spot_meta: Option<&Value>, dexes: &[String]) {
126        let mut assets = HashMap::new();
127        let mut assets_by_index = HashMap::new();
128
129        // Parse perp assets
130        if let Some(universe) = meta.get("universe").and_then(|u| u.as_array()) {
131            for (i, asset) in universe.iter().enumerate() {
132                if let Some(name) = asset.get("name").and_then(|n| n.as_str()) {
133                    let sz_decimals = asset
134                        .get("szDecimals")
135                        .and_then(|d| d.as_u64())
136                        .unwrap_or(8) as u8;
137
138                    let info = AssetInfo {
139                        index: i,
140                        name: name.to_string(),
141                        sz_decimals,
142                        is_spot: false,
143                    };
144                    assets.insert(name.to_string(), info.clone());
145                    assets_by_index.insert(i, info);
146                }
147            }
148        }
149
150        // Parse spot assets
151        if let Some(spot) = spot_meta {
152            if let Some(tokens) = spot.get("tokens").and_then(|t| t.as_array()) {
153                for token in tokens {
154                    if let (Some(name), Some(index)) = (
155                        token.get("name").and_then(|n| n.as_str()),
156                        token.get("index").and_then(|i| i.as_u64()),
157                    ) {
158                        let sz_decimals = token
159                            .get("szDecimals")
160                            .and_then(|d| d.as_u64())
161                            .unwrap_or(8) as u8;
162
163                        let info = AssetInfo {
164                            index: index as usize,
165                            name: name.to_string(),
166                            sz_decimals,
167                            is_spot: true,
168                        };
169                        assets.insert(name.to_string(), info.clone());
170                        assets_by_index.insert(index as usize, info);
171                    }
172                }
173            }
174        }
175
176        *self.assets.write() = assets;
177        *self.assets_by_index.write() = assets_by_index;
178        *self.dexes.write() = dexes.to_vec();
179        *self.last_update.write() = Some(SystemTime::now());
180    }
181}
182
183// ══════════════════════════════════════════════════════════════════════════════
184// SDK Inner (shared state)
185// ══════════════════════════════════════════════════════════════════════════════
186
187/// Parsed endpoint information
188#[derive(Debug, Clone)]
189pub struct EndpointInfo {
190    /// Base URL (scheme + host)
191    pub base: String,
192    /// Token extracted from URL path (if any)
193    pub token: Option<String>,
194    /// Whether this is a QuickNode endpoint
195    pub is_quicknode: bool,
196}
197
198impl EndpointInfo {
199    /// Parse endpoint URL and extract token
200    ///
201    /// Handles URLs like:
202    /// - `https://x.quiknode.pro/TOKEN/evm` -> base=`https://x.quiknode.pro`, token=`TOKEN`
203    /// - `https://x.quiknode.pro/TOKEN` -> base=`https://x.quiknode.pro`, token=`TOKEN`
204    /// - `https://api.hyperliquid.xyz/info` -> base=`https://api.hyperliquid.xyz`, token=None
205    pub fn parse(url: &str) -> Self {
206        let parsed = url::Url::parse(url).ok();
207
208        if let Some(parsed) = parsed {
209            let base = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
210            let is_quicknode = parsed.host_str().map(|h| h.contains("quiknode.pro")).unwrap_or(false);
211
212            // Extract path segments
213            let path_parts: Vec<&str> = parsed.path()
214                .trim_matches('/')
215                .split('/')
216                .filter(|p| !p.is_empty())
217                .collect();
218
219            // Find the token (first segment that's not a known path)
220            let token = path_parts.iter()
221                .find(|&part| !KNOWN_PATHS.contains(part))
222                .map(|s| s.to_string());
223
224            Self { base, token, is_quicknode }
225        } else {
226            // Fallback for unparseable URLs
227            Self {
228                base: url.to_string(),
229                token: None,
230                is_quicknode: url.contains("quiknode.pro"),
231            }
232        }
233    }
234
235    /// Build URL for a specific path suffix (e.g., "info", "hypercore", "evm")
236    pub fn build_url(&self, suffix: &str) -> String {
237        if let Some(ref token) = self.token {
238            format!("{}/{}/{}", self.base, token, suffix)
239        } else {
240            format!("{}/{}", self.base, suffix)
241        }
242    }
243
244    /// Build WebSocket URL
245    pub fn build_ws_url(&self) -> String {
246        let ws_base = self.base.replace("https://", "wss://").replace("http://", "ws://");
247        if let Some(ref token) = self.token {
248            format!("{}/{}/hypercore/ws", ws_base, token)
249        } else {
250            format!("{}/ws", ws_base)
251        }
252    }
253
254    /// Build gRPC URL (uses port 10000)
255    pub fn build_grpc_url(&self) -> String {
256        // gRPC uses the same host but port 10000
257        if let Some(ref token) = self.token {
258            let grpc_base = self.base.replace(":443", "").replace("https://", "");
259            format!("https://{}:10000/{}", grpc_base, token)
260        } else {
261            self.base.replace(":443", ":10000")
262        }
263    }
264}
265
266/// Shared SDK state
267pub struct HyperliquidSDKInner {
268    pub(crate) http_client: Client,
269    pub(crate) signer: Option<PrivateKeySigner>,
270    pub(crate) address: Option<Address>,
271    pub(crate) chain: Chain,
272    pub(crate) endpoint: Option<String>,
273    pub(crate) endpoint_info: Option<EndpointInfo>,
274    pub(crate) slippage: f64,
275    pub(crate) metadata: MetadataCache,
276    pub(crate) mid_prices: DashMap<String, f64>,
277}
278
279impl std::fmt::Debug for HyperliquidSDKInner {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        f.debug_struct("HyperliquidSDKInner")
282            .field("address", &self.address)
283            .field("chain", &self.chain)
284            .field("endpoint", &self.endpoint)
285            .field("slippage", &self.slippage)
286            .finish_non_exhaustive()
287    }
288}
289
290/// Exchange URL (worker handles ALL trading operations)
291const DEFAULT_EXCHANGE_URL: &str = "https://send.hyperliquidapi.com/exchange";
292
293impl HyperliquidSDKInner {
294    /// Get the exchange endpoint URL (for sending orders)
295    ///
296    /// ALL trading/exchange operations go through the worker at
297    /// `send.hyperliquidapi.com/exchange`. The QuickNode `/send` endpoint
298    /// is NOT used - QuickNode endpoints are only for info/hypercore/evm APIs.
299    fn exchange_url(&self) -> String {
300        DEFAULT_EXCHANGE_URL.to_string()
301    }
302
303    /// Get the info endpoint URL for a query type
304    fn info_url(&self, query_type: &str) -> String {
305        if let Some(ref info) = self.endpoint_info {
306            // QuickNode endpoint - check if query type is supported
307            if info.is_quicknode && QN_SUPPORTED_INFO_TYPES.contains(&query_type) {
308                return info.build_url("info");
309            }
310        }
311        // Fall back to worker for unsupported methods (worker proxies to public HL endpoint)
312        DEFAULT_WORKER_INFO_URL.to_string()
313    }
314
315    /// Get the HyperCore endpoint URL
316    pub fn hypercore_url(&self) -> String {
317        if let Some(ref info) = self.endpoint_info {
318            if info.is_quicknode {
319                return info.build_url("hypercore");
320            }
321        }
322        // No public HyperCore endpoint - fall back to info
323        HL_INFO_URL.to_string()
324    }
325
326    /// Get the EVM endpoint URL
327    pub fn evm_url(&self, use_nanoreth: bool) -> String {
328        if let Some(ref info) = self.endpoint_info {
329            if info.is_quicknode {
330                let suffix = if use_nanoreth { "nanoreth" } else { "evm" };
331                return info.build_url(suffix);
332            }
333        }
334        // Public EVM endpoints
335        match self.chain {
336            Chain::Mainnet => "https://rpc.hyperliquid.xyz/evm".to_string(),
337            Chain::Testnet => "https://rpc.hyperliquid-testnet.xyz/evm".to_string(),
338        }
339    }
340
341    /// Get the WebSocket URL
342    pub fn ws_url(&self) -> String {
343        if let Some(ref info) = self.endpoint_info {
344            return info.build_ws_url();
345        }
346        // Public WebSocket
347        "wss://api.hyperliquid.xyz/ws".to_string()
348    }
349
350    /// Get the gRPC URL
351    pub fn grpc_url(&self) -> String {
352        if let Some(ref info) = self.endpoint_info {
353            if info.is_quicknode {
354                return info.build_grpc_url();
355            }
356        }
357        // No public gRPC endpoint
358        String::new()
359    }
360
361    /// Make a POST request to the info endpoint
362    pub async fn query_info(&self, body: &Value) -> Result<Value> {
363        let query_type = body.get("type").and_then(|t| t.as_str()).unwrap_or("");
364        let url = self.info_url(query_type);
365
366        let response = self
367            .http_client
368            .post(&url)
369            .json(body)
370            .send()
371            .await?;
372
373        let status = response.status();
374        let text = response.text().await?;
375
376        if !status.is_success() {
377            return Err(Error::NetworkError(format!(
378                "Info endpoint returned {}: {}",
379                status, text
380            )));
381        }
382
383        serde_json::from_str(&text).map_err(|e| Error::JsonError(e.to_string()))
384    }
385
386    /// Build an action (get hash without sending)
387    pub async fn build_action(&self, action: &Value, slippage: Option<f64>) -> Result<BuildResponse> {
388        let url = self.exchange_url();
389
390        let mut body = json!({ "action": action });
391        if let Some(s) = slippage {
392            if !s.is_finite() || s <= 0.0 {
393                return Err(Error::ValidationError(
394                    "Slippage must be a positive finite number".to_string(),
395                ));
396            }
397            body["slippage"] = json!(s);
398        }
399
400        let response = self
401            .http_client
402            .post(url)
403            .json(&body)
404            .send()
405            .await?;
406
407        let status = response.status();
408        let text = response.text().await?;
409
410        if !status.is_success() {
411            return Err(Error::NetworkError(format!(
412                "Build request failed {}: {}",
413                status, text
414            )));
415        }
416
417        let result: Value = serde_json::from_str(&text)?;
418
419        // Check for error
420        if let Some(error) = result.get("error") {
421            return Err(Error::from_api_error(
422                error.as_str().unwrap_or("Unknown error"),
423            ));
424        }
425
426        Ok(BuildResponse {
427            hash: result
428                .get("hash")
429                .and_then(|h| h.as_str())
430                .unwrap_or("")
431                .to_string(),
432            nonce: result.get("nonce").and_then(|n| n.as_u64()).unwrap_or(0),
433            action: result.get("action").cloned().unwrap_or(action.clone()),
434        })
435    }
436
437    /// Send a signed action
438    pub async fn send_action(
439        &self,
440        action: &Value,
441        nonce: u64,
442        signature: &Signature,
443    ) -> Result<Value> {
444        let url = self.exchange_url();
445
446        let body = json!({
447            "action": action,
448            "nonce": nonce,
449            "signature": signature,
450        });
451
452        let response = self
453            .http_client
454            .post(url)
455            .json(&body)
456            .send()
457            .await?;
458
459        let status = response.status();
460        let text = response.text().await?;
461
462        if !status.is_success() {
463            return Err(Error::NetworkError(format!(
464                "Send request failed {}: {}",
465                status, text
466            )));
467        }
468
469        let result: Value = serde_json::from_str(&text)?;
470
471        // Check for API error
472        if let Some(hl_status) = result.get("status") {
473            if hl_status.as_str() == Some("err") {
474                if let Some(response) = result.get("response") {
475                    let raw = response.as_str()
476                        .map(|s| s.to_string())
477                        .unwrap_or_else(|| response.to_string());
478                    return Err(Error::from_api_error(&raw));
479                }
480            }
481        }
482
483        Ok(result)
484    }
485
486    /// Build, sign, and send an action
487    ///
488    /// If `slippage` is `Some`, it is included in the build payload for the worker
489    /// to apply when computing market order prices. When `None`, the constructor-level
490    /// default slippage is used (if > 0).
491    pub async fn build_sign_send(&self, action: &Value, slippage: Option<f64>) -> Result<Value> {
492        let signer = self
493            .signer
494            .as_ref()
495            .ok_or_else(|| Error::ConfigError("No private key configured".to_string()))?;
496
497        // Resolve effective slippage: per-call override > constructor default > omit
498        let effective_slippage = slippage.or_else(|| {
499            if self.slippage > 0.0 {
500                Some(self.slippage)
501            } else {
502                None
503            }
504        });
505
506        // Step 1: Build
507        let build_result = self.build_action(action, effective_slippage).await?;
508
509        // Step 2: Sign
510        let hash_bytes = hex::decode(build_result.hash.trim_start_matches("0x"))
511            .map_err(|e| Error::SigningError(format!("Invalid hash: {}", e)))?;
512
513        let hash = alloy::primitives::B256::from_slice(&hash_bytes);
514        let signature = sign_hash(signer, hash).await?;
515
516        // Step 3: Send
517        self.send_action(&build_result.action, build_result.nonce, &signature)
518            .await
519    }
520
521    /// Refresh metadata cache
522    pub async fn refresh_metadata(&self) -> Result<()> {
523        // Fetch perp meta
524        let meta = self.query_info(&json!({"type": "meta"})).await?;
525
526        // Fetch spot meta
527        let spot_meta = self.query_info(&json!({"type": "spotMeta"})).await.ok();
528
529        // Fetch DEXes
530        let dexes_result = self.query_info(&json!({"type": "perpDexs"})).await.ok();
531        let dexes: Vec<String> = dexes_result
532            .and_then(|v| {
533                v.as_array().map(|arr| {
534                    arr.iter()
535                        .filter_map(|d| d.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
536                        .collect()
537                })
538            })
539            .unwrap_or_default();
540
541        self.metadata.update(&meta, spot_meta.as_ref(), &dexes);
542
543        Ok(())
544    }
545
546    /// Fetch all mid prices
547    pub async fn fetch_all_mids(&self) -> Result<HashMap<String, f64>> {
548        let result = self.query_info(&json!({"type": "allMids"})).await?;
549
550        let mut mids = HashMap::new();
551        if let Some(obj) = result.as_object() {
552            for (coin, price_val) in obj {
553                let price_str = price_val.as_str().unwrap_or("");
554                if let Ok(price) = price_str.parse::<f64>() {
555                    mids.insert(coin.clone(), price);
556                    self.mid_prices.insert(coin.clone(), price);
557                }
558            }
559        }
560
561        // Also fetch HIP-3 mids
562        for dex in self.metadata.get_dexes() {
563            if let Ok(dex_result) = self.query_info(&json!({"type": "allMids", "dex": dex})).await {
564                if let Some(obj) = dex_result.as_object() {
565                    for (coin, price_val) in obj {
566                        let price_str = price_val.as_str().unwrap_or("");
567                        if let Ok(price) = price_str.parse::<f64>() {
568                            mids.insert(coin.clone(), price);
569                            self.mid_prices.insert(coin.clone(), price);
570                        }
571                    }
572                }
573            }
574        }
575
576        Ok(mids)
577    }
578
579    /// Get mid price for an asset (from cache or fetch)
580    pub async fn get_mid_price(&self, asset: &str) -> Result<f64> {
581        if let Some(price) = self.mid_prices.get(asset) {
582            return Ok(*price);
583        }
584
585        // Fetch all mids
586        let mids = self.fetch_all_mids().await?;
587        mids.get(asset)
588            .copied()
589            .ok_or_else(|| Error::ValidationError(format!("No price found for {}", asset)))
590    }
591
592    /// Resolve asset name to index
593    pub fn resolve_asset(&self, name: &str) -> Option<usize> {
594        self.metadata.resolve_asset(name)
595    }
596
597    /// Cancel an order by OID
598    pub async fn cancel_by_oid(&self, oid: u64, asset: &str) -> Result<Value> {
599        let asset_index = self
600            .resolve_asset(asset)
601            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
602
603        let action = json!({
604            "type": "cancel",
605            "cancels": [{
606                "a": asset_index,
607                "o": oid,
608            }]
609        });
610
611        self.build_sign_send(&action, None).await
612    }
613
614    /// Modify an order by OID
615    pub async fn modify_by_oid(
616        &self,
617        oid: u64,
618        asset: &str,
619        side: Side,
620        price: Decimal,
621        size: Decimal,
622    ) -> Result<PlacedOrder> {
623        let asset_index = self
624            .resolve_asset(asset)
625            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
626
627        let action = json!({
628            "type": "batchModify",
629            "modifies": [{
630                "oid": oid,
631                "order": {
632                    "a": asset_index,
633                    "b": side.is_buy(),
634                    "p": price.normalize().to_string(),
635                    "s": size.normalize().to_string(),
636                    "r": false,
637                    "t": {"limit": {"tif": "Gtc"}},
638                    "c": "0x00000000000000000000000000000000",
639                }
640            }]
641        });
642
643        let response = self.build_sign_send(&action, None).await?;
644
645        Ok(PlacedOrder::from_response(
646            response,
647            asset.to_string(),
648            side,
649            size,
650            Some(price),
651            None,
652        ))
653    }
654}
655
656/// Build response from the server
657#[derive(Debug)]
658pub struct BuildResponse {
659    pub hash: String,
660    pub nonce: u64,
661    pub action: Value,
662}
663
664// ══════════════════════════════════════════════════════════════════════════════
665// SDK Builder
666// ══════════════════════════════════════════════════════════════════════════════
667
668/// Builder for HyperliquidSDK
669#[derive(Default)]
670pub struct HyperliquidSDKBuilder {
671    endpoint: Option<String>,
672    private_key: Option<String>,
673    testnet: bool,
674    auto_approve: bool,
675    max_fee: String,
676    slippage: f64,
677    timeout: Duration,
678}
679
680impl HyperliquidSDKBuilder {
681    /// Create a new builder
682    pub fn new() -> Self {
683        Self {
684            endpoint: None,
685            private_key: None,
686            testnet: false,
687            auto_approve: true,
688            max_fee: "1%".to_string(),
689            slippage: DEFAULT_SLIPPAGE,
690            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
691        }
692    }
693
694    /// Set the QuickNode endpoint
695    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
696        self.endpoint = Some(endpoint.into());
697        self
698    }
699
700    /// Set the private key
701    pub fn private_key(mut self, key: impl Into<String>) -> Self {
702        self.private_key = Some(key.into());
703        self
704    }
705
706    /// Use testnet
707    pub fn testnet(mut self, testnet: bool) -> Self {
708        self.testnet = testnet;
709        self
710    }
711
712    /// Auto-approve builder fee on first trade
713    pub fn auto_approve(mut self, auto: bool) -> Self {
714        self.auto_approve = auto;
715        self
716    }
717
718    /// Set maximum builder fee
719    pub fn max_fee(mut self, fee: impl Into<String>) -> Self {
720        self.max_fee = fee.into();
721        self
722    }
723
724    /// Set slippage for market orders
725    pub fn slippage(mut self, slippage: f64) -> Self {
726        self.slippage = slippage;
727        self
728    }
729
730    /// Set request timeout
731    pub fn timeout(mut self, timeout: Duration) -> Self {
732        self.timeout = timeout;
733        self
734    }
735
736    /// Build the SDK
737    pub async fn build(self) -> Result<HyperliquidSDK> {
738        // Get private key from builder or environment
739        let private_key = self
740            .private_key
741            .or_else(|| std::env::var("PRIVATE_KEY").ok());
742
743        // Parse signer if key provided
744        let (signer, address) = if let Some(key) = private_key {
745            let key = key.trim_start_matches("0x");
746            let signer = PrivateKeySigner::from_str(key)?;
747            let address = signer.address();
748            (Some(signer), Some(address))
749        } else {
750            (None, None)
751        };
752
753        // Build HTTP client
754        let http_client = Client::builder()
755            .timeout(self.timeout)
756            .build()
757            .map_err(|e| Error::ConfigError(format!("Failed to create HTTP client: {}", e)))?;
758
759        let chain = if self.testnet {
760            Chain::Testnet
761        } else {
762            Chain::Mainnet
763        };
764
765        // Parse endpoint info for URL routing
766        let endpoint_info = self.endpoint.as_ref().map(|ep| EndpointInfo::parse(ep));
767
768        let inner = Arc::new(HyperliquidSDKInner {
769            http_client,
770            signer,
771            address,
772            chain,
773            endpoint: self.endpoint,
774            endpoint_info,
775            slippage: self.slippage,
776            metadata: MetadataCache::default(),
777            mid_prices: DashMap::new(),
778        });
779
780        // Refresh metadata
781        if let Err(e) = inner.refresh_metadata().await {
782            tracing::warn!("Failed to fetch initial metadata: {}", e);
783        }
784
785        Ok(HyperliquidSDK {
786            inner,
787            auto_approve: self.auto_approve,
788            max_fee: self.max_fee,
789        })
790    }
791}
792
793// ══════════════════════════════════════════════════════════════════════════════
794// Main SDK
795// ══════════════════════════════════════════════════════════════════════════════
796
797/// Main Hyperliquid SDK client
798pub struct HyperliquidSDK {
799    inner: Arc<HyperliquidSDKInner>,
800    #[allow(dead_code)]
801    auto_approve: bool,
802    max_fee: String,
803}
804
805impl HyperliquidSDK {
806    /// Create a new SDK builder
807    pub fn new() -> HyperliquidSDKBuilder {
808        HyperliquidSDKBuilder::new()
809    }
810
811    /// Get the user's address
812    pub fn address(&self) -> Option<Address> {
813        self.inner.address
814    }
815
816    /// Get the chain
817    pub fn chain(&self) -> Chain {
818        self.inner.chain
819    }
820
821    // ──────────────────────────────────────────────────────────────────────────
822    // Info API (lazy accessor)
823    // ──────────────────────────────────────────────────────────────────────────
824
825    /// Access the Info API
826    pub fn info(&self) -> crate::info::Info {
827        crate::info::Info::new(self.inner.clone())
828    }
829
830    /// Access the HyperCore API
831    pub fn core(&self) -> crate::hypercore::HyperCore {
832        crate::hypercore::HyperCore::new(self.inner.clone())
833    }
834
835    /// Access the EVM API
836    pub fn evm(&self) -> crate::evm::EVM {
837        crate::evm::EVM::new(self.inner.clone())
838    }
839
840    /// Create a WebSocket stream
841    pub fn stream(&self) -> crate::stream::Stream {
842        crate::stream::Stream::new(self.inner.endpoint.clone())
843    }
844
845    /// Create a gRPC stream
846    pub fn grpc(&self) -> crate::grpc::GRPCStream {
847        crate::grpc::GRPCStream::new(self.inner.endpoint.clone())
848    }
849
850    /// Access the EVM WebSocket stream
851    pub fn evm_stream(&self) -> crate::evm_stream::EVMStream {
852        crate::evm_stream::EVMStream::new(self.inner.endpoint.clone())
853    }
854
855    // ──────────────────────────────────────────────────────────────────────────
856    // Quick Queries
857    // ──────────────────────────────────────────────────────────────────────────
858
859    /// Get all available markets
860    pub async fn markets(&self) -> Result<Value> {
861        self.inner.query_info(&json!({"type": "meta"})).await
862    }
863
864    /// Get all DEXes (HIP-3)
865    pub async fn dexes(&self) -> Result<Value> {
866        self.inner.query_info(&json!({"type": "perpDexs"})).await
867    }
868
869    /// Get open orders for the current user
870    pub async fn open_orders(&self) -> Result<Value> {
871        let address = self
872            .inner
873            .address
874            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
875
876        self.inner
877            .query_info(&json!({
878                "type": "openOrders",
879                "user": format!("{:?}", address),
880            }))
881            .await
882    }
883
884    /// Get status of a specific order
885    pub async fn order_status(&self, oid: u64, dex: Option<&str>) -> Result<Value> {
886        let address = self
887            .inner
888            .address
889            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
890
891        let mut req = json!({
892            "type": "orderStatus",
893            "user": format!("{:?}", address),
894            "oid": oid,
895        });
896
897        if let Some(d) = dex {
898            req["dex"] = json!(d);
899        }
900
901        self.inner.query_info(&req).await
902    }
903
904    // ──────────────────────────────────────────────────────────────────────────
905    // Order Placement
906    // ──────────────────────────────────────────────────────────────────────────
907
908    /// Place a market buy order
909    pub async fn market_buy(&self, asset: &str) -> MarketOrderBuilder {
910        MarketOrderBuilder::new(self.inner.clone(), asset.to_string(), Side::Buy)
911    }
912
913    /// Place a market sell order
914    pub async fn market_sell(&self, asset: &str) -> MarketOrderBuilder {
915        MarketOrderBuilder::new(self.inner.clone(), asset.to_string(), Side::Sell)
916    }
917
918    /// Place a limit buy order
919    pub async fn buy(
920        &self,
921        asset: &str,
922        size: f64,
923        price: f64,
924        tif: TIF,
925    ) -> Result<PlacedOrder> {
926        self.place_order(asset, Side::Buy, size, Some(price), tif, false, false, None)
927            .await
928    }
929
930    /// Place a limit sell order
931    pub async fn sell(
932        &self,
933        asset: &str,
934        size: f64,
935        price: f64,
936        tif: TIF,
937    ) -> Result<PlacedOrder> {
938        self.place_order(asset, Side::Sell, size, Some(price), tif, false, false, None)
939            .await
940    }
941
942    /// Place an order using the fluent builder
943    pub async fn order(&self, order: Order) -> Result<PlacedOrder> {
944        order.validate()?;
945
946        let asset = order.get_asset();
947        let side = order.get_side();
948        let tif = order.get_tif();
949
950        // Resolve size from notional if needed
951        let size = if let Some(s) = order.get_size() {
952            s
953        } else if let Some(notional) = order.get_notional() {
954            let mid = self.inner.get_mid_price(asset).await?;
955            Decimal::from_f64_retain(notional.to_string().parse::<f64>().unwrap_or(0.0) / mid)
956                .unwrap_or_default()
957        } else {
958            return Err(Error::ValidationError(
959                "Order must have size or notional".to_string(),
960            ));
961        };
962
963        // For market orders, delegate price computation to the worker.
964        // For limit orders, use the user-specified price.
965        let is_market = order.is_market();
966        let price = if is_market {
967            None // worker computes price from mid + slippage
968        } else {
969            order
970                .get_price()
971                .map(|p| p.to_string().parse::<f64>().unwrap_or(0.0))
972        };
973
974        self.place_order(
975            asset,
976            side,
977            size.to_string().parse::<f64>().unwrap_or(0.0),
978            price,
979            if is_market { TIF::Market } else { tif },
980            order.is_reduce_only(),
981            is_market,
982            None, // use constructor-level default slippage
983        )
984        .await
985    }
986
987    /// Place a trigger order (stop-loss / take-profit)
988    pub async fn trigger_order(&self, order: TriggerOrder) -> Result<PlacedOrder> {
989        order.validate()?;
990
991        let asset = order.get_asset();
992        let asset_index = self
993            .inner
994            .resolve_asset(asset)
995            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
996
997        // Get size decimals for rounding
998        let sz_decimals = self.inner.metadata.get_asset(asset)
999            .map(|a| a.sz_decimals)
1000            .unwrap_or(5) as u32;
1001
1002        let trigger_px = order
1003            .get_trigger_price()
1004            .ok_or_else(|| Error::ValidationError("Trigger price required".to_string()))?;
1005
1006        let size = order
1007            .get_size()
1008            .ok_or_else(|| Error::ValidationError("Size required".to_string()))?;
1009
1010        // Round size to allowed decimals
1011        let size_rounded = size.round_dp(sz_decimals);
1012
1013        // Get execution price, rounded to valid tick
1014        let limit_px = if order.is_market() {
1015            let mid = self.inner.get_mid_price(asset).await?;
1016            let slippage = self.inner.slippage;
1017            let price = if order.get_side().is_buy() {
1018                mid * (1.0 + slippage)
1019            } else {
1020                mid * (1.0 - slippage)
1021            };
1022            Decimal::from_f64_retain(price.round()).unwrap_or_default()
1023        } else {
1024            order.get_limit_price().unwrap_or(trigger_px).round()
1025        };
1026
1027        // Round trigger price
1028        let trigger_px_rounded = trigger_px.round();
1029
1030        // Generate random cloid (Hyperliquid requires nonzero cloid)
1031        let cloid = {
1032            let now = std::time::SystemTime::now()
1033                .duration_since(std::time::UNIX_EPOCH)
1034                .unwrap_or_default();
1035            let nanos = now.as_nanos() as u64;
1036            let hi = nanos.wrapping_mul(0x517cc1b727220a95);
1037            format!("0x{:016x}{:016x}", nanos, hi)
1038        };
1039
1040        let action = json!({
1041            "type": "order",
1042            "orders": [{
1043                "a": asset_index,
1044                "b": order.get_side().is_buy(),
1045                "p": limit_px.normalize().to_string(),
1046                "s": size_rounded.normalize().to_string(),
1047                "r": order.is_reduce_only(),
1048                "t": {
1049                    "trigger": {
1050                        "isMarket": order.is_market(),
1051                        "triggerPx": trigger_px_rounded.normalize().to_string(),
1052                        "tpsl": order.get_tpsl().to_string(),
1053                    }
1054                },
1055                "c": cloid,
1056            }],
1057            "grouping": "na",
1058        });
1059
1060        let response = self.inner.build_sign_send(&action, None).await?;
1061
1062        Ok(PlacedOrder::from_response(
1063            response,
1064            asset.to_string(),
1065            order.get_side(),
1066            size,
1067            Some(limit_px),
1068            Some(self.inner.clone()),
1069        ))
1070    }
1071
1072    /// Stop-loss helper
1073    pub async fn stop_loss(
1074        &self,
1075        asset: &str,
1076        size: f64,
1077        trigger_price: f64,
1078    ) -> Result<PlacedOrder> {
1079        self.trigger_order(
1080            TriggerOrder::stop_loss(asset)
1081                .size(size)
1082                .trigger_price(trigger_price)
1083                .market(),
1084        )
1085        .await
1086    }
1087
1088    /// Take-profit helper
1089    pub async fn take_profit(
1090        &self,
1091        asset: &str,
1092        size: f64,
1093        trigger_price: f64,
1094    ) -> Result<PlacedOrder> {
1095        self.trigger_order(
1096            TriggerOrder::take_profit(asset)
1097                .size(size)
1098                .trigger_price(trigger_price)
1099                .market(),
1100        )
1101        .await
1102    }
1103
1104    /// Internal order placement
1105    ///
1106    /// For market orders (`is_market = true`), uses the human-readable format
1107    /// (`asset`, `side`, `size`, `tif: "market"`) and delegates price computation
1108    /// to the worker. For limit orders, uses the wire format (`a`, `b`, `p`, `s`).
1109    async fn place_order(
1110        &self,
1111        asset: &str,
1112        side: Side,
1113        size: f64,
1114        price: Option<f64>,
1115        tif: TIF,
1116        reduce_only: bool,
1117        is_market: bool,
1118        slippage: Option<f64>,
1119    ) -> Result<PlacedOrder> {
1120        // Get size decimals for rounding
1121        let sz_decimals = self.inner.metadata.get_asset(asset)
1122            .map(|a| a.sz_decimals)
1123            .unwrap_or(5) as i32;
1124
1125        // Round size to allowed decimals
1126        let size_rounded = (size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals);
1127
1128        let (action, effective_slippage) = if is_market {
1129            // Market orders: use human-readable format, let worker compute price
1130            let mut order_spec = json!({
1131                "asset": asset,
1132                "side": if side.is_buy() { "buy" } else { "sell" },
1133                "size": format!("{}", size_rounded),
1134                "tif": "market",
1135            });
1136            if reduce_only {
1137                order_spec["reduceOnly"] = json!(true);
1138            }
1139            let action = json!({
1140                "type": "order",
1141                "orders": [order_spec],
1142            });
1143            (action, slippage)
1144        } else {
1145            // Limit orders: use wire format with asset index
1146            let asset_index = self
1147                .inner
1148                .resolve_asset(asset)
1149                .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1150
1151            let resolved_price = price.map(|p| p.round()).unwrap_or(0.0);
1152
1153            let tif_wire = match tif {
1154                TIF::Ioc => "Ioc",
1155                TIF::Gtc => "Gtc",
1156                TIF::Alo => "Alo",
1157                TIF::Market => "Ioc",
1158            };
1159
1160            // Generate random cloid (Hyperliquid requires nonzero cloid)
1161            let cloid = {
1162                let now = std::time::SystemTime::now()
1163                    .duration_since(std::time::UNIX_EPOCH)
1164                    .unwrap_or_default();
1165                let nanos = now.as_nanos() as u64;
1166                let hi = nanos.wrapping_mul(0x517cc1b727220a95);
1167                format!("0x{:016x}{:016x}", nanos, hi)
1168            };
1169
1170            let action = json!({
1171                "type": "order",
1172                "orders": [{
1173                    "a": asset_index,
1174                    "b": side.is_buy(),
1175                    "p": format!("{}", resolved_price),
1176                    "s": format!("{}", size_rounded),
1177                    "r": reduce_only,
1178                    "t": {"limit": {"tif": tif_wire}},
1179                    "c": cloid,
1180                }],
1181                "grouping": "na",
1182            });
1183            (action, None) // use constructor-level default (worker ignores slippage for limit orders)
1184        };
1185
1186        let response = self.inner.build_sign_send(&action, effective_slippage).await?;
1187
1188        Ok(PlacedOrder::from_response(
1189            response,
1190            asset.to_string(),
1191            side,
1192            Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
1193            price.map(|p| Decimal::from_f64_retain(p).unwrap_or_default()),
1194            Some(self.inner.clone()),
1195        ))
1196    }
1197
1198    // ──────────────────────────────────────────────────────────────────────────
1199    // Order Management
1200    // ──────────────────────────────────────────────────────────────────────────
1201
1202    /// Modify an existing order
1203    ///
1204    /// The order is identified by OID, which is included in the returned order.
1205    pub async fn modify(
1206        &self,
1207        oid: u64,
1208        asset: &str,
1209        is_buy: bool,
1210        size: f64,
1211        price: f64,
1212        tif: TIF,
1213        reduce_only: bool,
1214        cloid: Option<&str>,
1215    ) -> Result<PlacedOrder> {
1216        let asset_idx = self
1217            .inner
1218            .metadata
1219            .resolve_asset(asset)
1220            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1221
1222        let sz_decimals = self.inner.metadata.get_asset(asset)
1223            .map(|a| a.sz_decimals)
1224            .unwrap_or(8) as i32;
1225        let size_rounded = (size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals);
1226
1227        let order_type = match tif {
1228            TIF::Gtc => json!({"limit": {"tif": "Gtc"}}),
1229            TIF::Ioc | TIF::Market => json!({"limit": {"tif": "Ioc"}}),
1230            TIF::Alo => json!({"limit": {"tif": "Alo"}}),
1231        };
1232
1233        let cloid_val = cloid
1234            .map(|s| s.to_string())
1235            .unwrap_or_else(|| {
1236                let now = std::time::SystemTime::now()
1237                    .duration_since(std::time::UNIX_EPOCH)
1238                    .unwrap_or_default();
1239                let nanos = now.as_nanos() as u64;
1240                let hi = nanos.wrapping_mul(0x517cc1b727220a95);
1241                format!("0x{:016x}{:016x}", nanos, hi)
1242            });
1243
1244        let action = json!({
1245            "type": "batchModify",
1246            "modifies": [{
1247                "oid": oid,
1248                "order": {
1249                    "a": asset_idx,
1250                    "b": is_buy,
1251                    "p": format!("{:.8}", price).trim_end_matches('0').trim_end_matches('.'),
1252                    "s": format!("{:.8}", size_rounded).trim_end_matches('0').trim_end_matches('.'),
1253                    "r": reduce_only,
1254                    "t": order_type,
1255                    "c": cloid_val,
1256                }
1257            }]
1258        });
1259
1260        let response = self.inner.build_sign_send(&action, None).await?;
1261
1262        Ok(PlacedOrder::from_response(
1263            response,
1264            asset.to_string(),
1265            if is_buy { Side::Buy } else { Side::Sell },
1266            Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
1267            Some(Decimal::from_f64_retain(price).unwrap_or_default()),
1268            Some(self.inner.clone()),
1269        ))
1270    }
1271
1272    /// Cancel an order by OID
1273    pub async fn cancel(&self, oid: u64, asset: &str) -> Result<Value> {
1274        self.inner.cancel_by_oid(oid, asset).await
1275    }
1276
1277    /// Cancel all orders (optionally for a specific asset)
1278    pub async fn cancel_all(&self, asset: Option<&str>) -> Result<Value> {
1279        // Ensure we have an address configured
1280        if self.inner.address.is_none() {
1281            return Err(Error::ConfigError("No address configured".to_string()));
1282        }
1283
1284        // Get open orders
1285        let open_orders = self.open_orders().await?;
1286
1287        let cancels: Vec<Value> = open_orders
1288            .as_array()
1289            .unwrap_or(&vec![])
1290            .iter()
1291            .filter(|order| {
1292                if let Some(asset) = asset {
1293                    order.get("coin").and_then(|c| c.as_str()) == Some(asset)
1294                } else {
1295                    true
1296                }
1297            })
1298            .filter_map(|order| {
1299                let oid = order.get("oid").and_then(|o| o.as_u64())?;
1300                let coin = order.get("coin").and_then(|c| c.as_str())?;
1301                let asset_index = self.inner.resolve_asset(coin)?;
1302                Some(json!({"a": asset_index, "o": oid}))
1303            })
1304            .collect();
1305
1306        if cancels.is_empty() {
1307            return Ok(json!({"status": "ok", "message": "No orders to cancel"}));
1308        }
1309
1310        let action = json!({
1311            "type": "cancel",
1312            "cancels": cancels,
1313        });
1314
1315        self.inner.build_sign_send(&action, None).await
1316    }
1317
1318    /// Close position for an asset
1319    ///
1320    /// Delegates position lookup and counter-order building to the worker using
1321    /// the `closePosition` action type. Optionally accepts a per-call slippage
1322    /// override.
1323    pub async fn close_position(&self, asset: &str, slippage: Option<f64>) -> Result<PlacedOrder> {
1324        let address = self
1325            .inner
1326            .address
1327            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
1328
1329        let action = json!({
1330            "type": "closePosition",
1331            "asset": asset,
1332            "user": format!("{:?}", address),
1333        });
1334
1335        let response = self.inner.build_sign_send(&action, slippage).await?;
1336
1337        // Build pseudo PlacedOrder — actual fill data is extracted from exchangeResponse
1338        // by PlacedOrder::from_response (matching TS/Python pattern)
1339        Ok(PlacedOrder::from_response(
1340            response,
1341            asset.to_string(),
1342            Side::Sell,    // placeholder — actual side determined by API response
1343            Decimal::ZERO, // placeholder — actual size from fill data
1344            None,
1345            Some(self.inner.clone()),
1346        ))
1347    }
1348
1349    // ──────────────────────────────────────────────────────────────────────────
1350    // Leverage & Margin
1351    // ──────────────────────────────────────────────────────────────────────────
1352
1353    /// Update leverage for an asset
1354    pub async fn update_leverage(
1355        &self,
1356        asset: &str,
1357        leverage: i32,
1358        is_cross: bool,
1359    ) -> Result<Value> {
1360        let asset_index = self
1361            .inner
1362            .resolve_asset(asset)
1363            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1364
1365        let action = json!({
1366            "type": "updateLeverage",
1367            "asset": asset_index,
1368            "isCross": is_cross,
1369            "leverage": leverage,
1370        });
1371
1372        self.inner.build_sign_send(&action, None).await
1373    }
1374
1375    /// Update isolated margin
1376    pub async fn update_isolated_margin(
1377        &self,
1378        asset: &str,
1379        is_buy: bool,
1380        amount_usd: f64,
1381    ) -> Result<Value> {
1382        let asset_index = self
1383            .inner
1384            .resolve_asset(asset)
1385            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1386
1387        let action = json!({
1388            "type": "updateIsolatedMargin",
1389            "asset": asset_index,
1390            "isBuy": is_buy,
1391            "ntli": (amount_usd * 1_000_000.0) as i64, // Convert to USDC with 6 decimals
1392        });
1393
1394        self.inner.build_sign_send(&action, None).await
1395    }
1396
1397    // ──────────────────────────────────────────────────────────────────────────
1398    // TWAP Orders
1399    // ──────────────────────────────────────────────────────────────────────────
1400
1401    /// Place a TWAP order
1402    pub async fn twap_order(
1403        &self,
1404        asset: &str,
1405        size: f64,
1406        is_buy: bool,
1407        duration_minutes: i64,
1408        reduce_only: bool,
1409        randomize: bool,
1410    ) -> Result<Value> {
1411        let action = json!({
1412            "type": "twapOrder",
1413            "twap": {
1414                "a": asset,
1415                "b": is_buy,
1416                "s": format!("{}", size),
1417                "r": reduce_only,
1418                "m": duration_minutes,
1419                "t": randomize,
1420            }
1421        });
1422
1423        self.inner.build_sign_send(&action, None).await
1424    }
1425
1426    /// Cancel a TWAP order
1427    pub async fn twap_cancel(&self, asset: &str, twap_id: i64) -> Result<Value> {
1428        let action = json!({
1429            "type": "twapCancel",
1430            "a": asset,
1431            "t": twap_id,
1432        });
1433
1434        self.inner.build_sign_send(&action, None).await
1435    }
1436
1437    // ──────────────────────────────────────────────────────────────────────────
1438    // Transfers
1439    // ──────────────────────────────────────────────────────────────────────────
1440
1441    /// Transfer USD to another address
1442    pub async fn transfer_usd(&self, destination: &str, amount: f64) -> Result<Value> {
1443        let time = SystemTime::now()
1444            .duration_since(UNIX_EPOCH)
1445            .unwrap()
1446            .as_millis() as u64;
1447
1448        let action = json!({
1449            "type": "usdSend",
1450            "hyperliquidChain": self.inner.chain.to_string(),
1451            "signatureChainId": self.inner.chain.signature_chain_id(),
1452            "destination": destination,
1453            "amount": format!("{}", amount),
1454            "time": time,
1455        });
1456
1457        self.inner.build_sign_send(&action, None).await
1458    }
1459
1460    /// Transfer spot token to another address
1461    pub async fn transfer_spot(
1462        &self,
1463        token: &str,
1464        destination: &str,
1465        amount: f64,
1466    ) -> Result<Value> {
1467        let time = SystemTime::now()
1468            .duration_since(UNIX_EPOCH)
1469            .unwrap()
1470            .as_millis() as u64;
1471
1472        let action = json!({
1473            "type": "spotSend",
1474            "hyperliquidChain": self.inner.chain.to_string(),
1475            "signatureChainId": self.inner.chain.signature_chain_id(),
1476            "token": token,
1477            "destination": destination,
1478            "amount": format!("{}", amount),
1479            "time": time,
1480        });
1481
1482        self.inner.build_sign_send(&action, None).await
1483    }
1484
1485    /// Withdraw to Arbitrum
1486    pub async fn withdraw(&self, amount: f64, destination: Option<&str>) -> Result<Value> {
1487        let time = SystemTime::now()
1488            .duration_since(UNIX_EPOCH)
1489            .unwrap()
1490            .as_millis() as u64;
1491
1492        let dest = destination
1493            .map(|s| s.to_string())
1494            .or_else(|| self.inner.address.map(|a| format!("{:?}", a)))
1495            .ok_or_else(|| Error::ConfigError("No destination address".to_string()))?;
1496
1497        let action = json!({
1498            "type": "withdraw3",
1499            "hyperliquidChain": self.inner.chain.to_string(),
1500            "signatureChainId": self.inner.chain.signature_chain_id(),
1501            "destination": dest,
1502            "amount": format!("{}", amount),
1503            "time": time,
1504        });
1505
1506        self.inner.build_sign_send(&action, None).await
1507    }
1508
1509    /// Transfer spot balance to perp balance
1510    pub async fn transfer_spot_to_perp(&self, amount: f64) -> Result<Value> {
1511        let nonce = SystemTime::now()
1512            .duration_since(UNIX_EPOCH)
1513            .unwrap()
1514            .as_millis() as u64;
1515
1516        let action = json!({
1517            "type": "usdClassTransfer",
1518            "hyperliquidChain": self.inner.chain.to_string(),
1519            "signatureChainId": self.inner.chain.signature_chain_id(),
1520            "amount": format!("{}", amount),
1521            "toPerp": true,
1522            "nonce": nonce,
1523        });
1524
1525        self.inner.build_sign_send(&action, None).await
1526    }
1527
1528    /// Transfer perp balance to spot balance
1529    pub async fn transfer_perp_to_spot(&self, amount: f64) -> Result<Value> {
1530        let nonce = SystemTime::now()
1531            .duration_since(UNIX_EPOCH)
1532            .unwrap()
1533            .as_millis() as u64;
1534
1535        let action = json!({
1536            "type": "usdClassTransfer",
1537            "hyperliquidChain": self.inner.chain.to_string(),
1538            "signatureChainId": self.inner.chain.signature_chain_id(),
1539            "amount": format!("{}", amount),
1540            "toPerp": false,
1541            "nonce": nonce,
1542        });
1543
1544        self.inner.build_sign_send(&action, None).await
1545    }
1546
1547    // ──────────────────────────────────────────────────────────────────────────
1548    // Vaults
1549    // ──────────────────────────────────────────────────────────────────────────
1550
1551    /// Deposit to a vault
1552    pub async fn vault_deposit(&self, vault_address: &str, amount: f64) -> Result<Value> {
1553        let action = json!({
1554            "type": "vaultTransfer",
1555            "vaultAddress": vault_address,
1556            "isDeposit": true,
1557            "usd": amount,
1558        });
1559
1560        self.inner.build_sign_send(&action, None).await
1561    }
1562
1563    /// Withdraw from a vault
1564    pub async fn vault_withdraw(&self, vault_address: &str, amount: f64) -> Result<Value> {
1565        let action = json!({
1566            "type": "vaultTransfer",
1567            "vaultAddress": vault_address,
1568            "isDeposit": false,
1569            "usd": amount,
1570        });
1571
1572        self.inner.build_sign_send(&action, None).await
1573    }
1574
1575    // ──────────────────────────────────────────────────────────────────────────
1576    // Staking
1577    // ──────────────────────────────────────────────────────────────────────────
1578
1579    /// Stake tokens
1580    pub async fn stake(&self, amount_tokens: f64) -> Result<Value> {
1581        let nonce = SystemTime::now()
1582            .duration_since(UNIX_EPOCH)
1583            .unwrap()
1584            .as_millis() as u64;
1585
1586        let wei = (amount_tokens * 1e18) as u128;
1587
1588        let action = json!({
1589            "type": "cDeposit",
1590            "hyperliquidChain": self.inner.chain.to_string(),
1591            "signatureChainId": self.inner.chain.signature_chain_id(),
1592            "wei": wei.to_string(),
1593            "nonce": nonce,
1594        });
1595
1596        self.inner.build_sign_send(&action, None).await
1597    }
1598
1599    /// Unstake tokens
1600    pub async fn unstake(&self, amount_tokens: f64) -> Result<Value> {
1601        let nonce = SystemTime::now()
1602            .duration_since(UNIX_EPOCH)
1603            .unwrap()
1604            .as_millis() as u64;
1605
1606        let wei = (amount_tokens * 1e18) as u128;
1607
1608        let action = json!({
1609            "type": "cWithdraw",
1610            "hyperliquidChain": self.inner.chain.to_string(),
1611            "signatureChainId": self.inner.chain.signature_chain_id(),
1612            "wei": wei.to_string(),
1613            "nonce": nonce,
1614        });
1615
1616        self.inner.build_sign_send(&action, None).await
1617    }
1618
1619    /// Delegate tokens to a validator
1620    pub async fn delegate(&self, validator: &str, amount_tokens: f64) -> Result<Value> {
1621        let nonce = SystemTime::now()
1622            .duration_since(UNIX_EPOCH)
1623            .unwrap()
1624            .as_millis() as u64;
1625
1626        let wei = (amount_tokens * 1e18) as u128;
1627
1628        let action = json!({
1629            "type": "tokenDelegate",
1630            "hyperliquidChain": self.inner.chain.to_string(),
1631            "signatureChainId": self.inner.chain.signature_chain_id(),
1632            "validator": validator,
1633            "isUndelegate": false,
1634            "wei": wei.to_string(),
1635            "nonce": nonce,
1636        });
1637
1638        self.inner.build_sign_send(&action, None).await
1639    }
1640
1641    /// Undelegate tokens from a validator
1642    pub async fn undelegate(&self, validator: &str, amount_tokens: f64) -> Result<Value> {
1643        let nonce = SystemTime::now()
1644            .duration_since(UNIX_EPOCH)
1645            .unwrap()
1646            .as_millis() as u64;
1647
1648        let wei = (amount_tokens * 1e18) as u128;
1649
1650        let action = json!({
1651            "type": "tokenDelegate",
1652            "hyperliquidChain": self.inner.chain.to_string(),
1653            "signatureChainId": self.inner.chain.signature_chain_id(),
1654            "validator": validator,
1655            "isUndelegate": true,
1656            "wei": wei.to_string(),
1657            "nonce": nonce,
1658        });
1659
1660        self.inner.build_sign_send(&action, None).await
1661    }
1662
1663    // ──────────────────────────────────────────────────────────────────────────
1664    // Builder Fee Approval
1665    // ──────────────────────────────────────────────────────────────────────────
1666
1667    /// Approve builder fee
1668    pub async fn approve_builder_fee(&self, max_fee: Option<&str>) -> Result<Value> {
1669        let fee = max_fee.unwrap_or(&self.max_fee);
1670
1671        let action = json!({
1672            "type": "approveBuilderFee",
1673            "maxFeeRate": fee,
1674        });
1675
1676        self.inner.build_sign_send(&action, None).await
1677    }
1678
1679    /// Revoke builder fee approval
1680    pub async fn revoke_builder_fee(&self) -> Result<Value> {
1681        self.approve_builder_fee(Some("0%")).await
1682    }
1683
1684    /// Check approval status
1685    pub async fn approval_status(&self) -> Result<Value> {
1686        let address = self
1687            .inner
1688            .address
1689            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
1690
1691        // Use the worker's /approval endpoint
1692        let url = format!("{}/approval", DEFAULT_WORKER_URL);
1693
1694        let response = self
1695            .inner
1696            .http_client
1697            .post(&url)
1698            .json(&json!({"user": format!("{:?}", address)}))
1699            .send()
1700            .await?;
1701
1702        let text = response.text().await?;
1703        serde_json::from_str(&text).map_err(|e| Error::JsonError(e.to_string()))
1704    }
1705
1706    // ──────────────────────────────────────────────────────────────────────────
1707    // Misc
1708    // ──────────────────────────────────────────────────────────────────────────
1709
1710    /// Reserve request weight (purchase rate limit capacity)
1711    pub async fn reserve_request_weight(&self, weight: i32) -> Result<Value> {
1712        let action = json!({
1713            "type": "reserveRequestWeight",
1714            "weight": weight,
1715        });
1716
1717        self.inner.build_sign_send(&action, None).await
1718    }
1719
1720    /// No-op (consume nonce)
1721    pub async fn noop(&self) -> Result<Value> {
1722        let action = json!({"type": "noop"});
1723        self.inner.build_sign_send(&action, None).await
1724    }
1725
1726    /// Preflight validation
1727    pub async fn preflight(
1728        &self,
1729        asset: &str,
1730        side: Side,
1731        price: f64,
1732        size: f64,
1733    ) -> Result<Value> {
1734        let url = format!("{}/preflight", DEFAULT_WORKER_URL);
1735
1736        let body = json!({
1737            "asset": asset,
1738            "side": side.to_string(),
1739            "price": price,
1740            "size": size,
1741        });
1742
1743        let response = self
1744            .inner
1745            .http_client
1746            .post(&url)
1747            .json(&body)
1748            .send()
1749            .await?;
1750
1751        let text = response.text().await?;
1752        serde_json::from_str(&text).map_err(|e| Error::JsonError(e.to_string()))
1753    }
1754
1755    // ──────────────────────────────────────────────────────────────────────────
1756    // Agent/API Key Management
1757    // ──────────────────────────────────────────────────────────────────────────
1758
1759    /// Approve an agent (API wallet) to trade on your behalf
1760    pub async fn approve_agent(
1761        &self,
1762        agent_address: &str,
1763        name: Option<&str>,
1764    ) -> Result<Value> {
1765        let nonce = SystemTime::now()
1766            .duration_since(UNIX_EPOCH)
1767            .unwrap()
1768            .as_millis() as u64;
1769
1770        let action = json!({
1771            "type": "approveAgent",
1772            "hyperliquidChain": self.inner.chain.as_str(),
1773            "signatureChainId": self.inner.chain.signature_chain_id(),
1774            "agentAddress": agent_address,
1775            "agentName": name,
1776            "nonce": nonce,
1777        });
1778
1779        self.inner.build_sign_send(&action, None).await
1780    }
1781
1782    // ──────────────────────────────────────────────────────────────────────────
1783    // Account Abstraction
1784    // ──────────────────────────────────────────────────────────────────────────
1785
1786    /// Set account abstraction mode
1787    ///
1788    /// Mode can be: "disabled", "unifiedAccount", or "portfolioMargin"
1789    pub async fn set_abstraction(&self, mode: &str, user: Option<&str>) -> Result<Value> {
1790        let address = self
1791            .inner
1792            .address
1793            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
1794
1795        let addr_string = format!("{:?}", address);
1796        let user_addr = user.unwrap_or(&addr_string);
1797        let nonce = SystemTime::now()
1798            .duration_since(UNIX_EPOCH)
1799            .unwrap()
1800            .as_millis() as u64;
1801
1802        let action = json!({
1803            "type": "userSetAbstraction",
1804            "hyperliquidChain": self.inner.chain.as_str(),
1805            "signatureChainId": self.inner.chain.signature_chain_id(),
1806            "user": user_addr,
1807            "abstraction": mode,
1808            "nonce": nonce,
1809        });
1810
1811        self.inner.build_sign_send(&action, None).await
1812    }
1813
1814    /// Set account abstraction mode as an agent
1815    pub async fn agent_set_abstraction(&self, mode: &str) -> Result<Value> {
1816        // Map full mode names to short codes
1817        let short_mode = match mode {
1818            "disabled" | "i" => "i",
1819            "unifiedAccount" | "u" => "u",
1820            "portfolioMargin" | "p" => "p",
1821            _ => {
1822                return Err(Error::ValidationError(format!(
1823                    "Invalid mode: {}. Use 'disabled', 'unifiedAccount', or 'portfolioMargin'",
1824                    mode
1825                )))
1826            }
1827        };
1828
1829        let action = json!({
1830            "type": "agentSetAbstraction",
1831            "abstraction": short_mode,
1832        });
1833
1834        self.inner.build_sign_send(&action, None).await
1835    }
1836
1837    // ──────────────────────────────────────────────────────────────────────────
1838    // Advanced Transfers
1839    // ──────────────────────────────────────────────────────────────────────────
1840
1841    /// Generalized asset transfer between DEXs and accounts
1842    pub async fn send_asset(
1843        &self,
1844        token: &str,
1845        amount: f64,
1846        destination: &str,
1847        source_dex: Option<&str>,
1848        destination_dex: Option<&str>,
1849        from_sub_account: Option<&str>,
1850    ) -> Result<Value> {
1851        let nonce = SystemTime::now()
1852            .duration_since(UNIX_EPOCH)
1853            .unwrap()
1854            .as_millis() as u64;
1855
1856        let action = json!({
1857            "type": "sendAsset",
1858            "hyperliquidChain": self.inner.chain.as_str(),
1859            "signatureChainId": self.inner.chain.signature_chain_id(),
1860            "destination": destination,
1861            "sourceDex": source_dex.unwrap_or(""),
1862            "destinationDex": destination_dex.unwrap_or(""),
1863            "token": token,
1864            "amount": amount.to_string(),
1865            "fromSubAccount": from_sub_account.unwrap_or(""),
1866            "nonce": nonce,
1867        });
1868
1869        self.inner.build_sign_send(&action, None).await
1870    }
1871
1872    /// Transfer tokens to HyperEVM with custom data payload
1873    pub async fn send_to_evm_with_data(
1874        &self,
1875        token: &str,
1876        amount: f64,
1877        destination: &str,
1878        data: &str,
1879        source_dex: &str,
1880        destination_chain_id: u32,
1881        gas_limit: u64,
1882    ) -> Result<Value> {
1883        let nonce = SystemTime::now()
1884            .duration_since(UNIX_EPOCH)
1885            .unwrap()
1886            .as_millis() as u64;
1887
1888        let action = json!({
1889            "type": "sendToEvmWithData",
1890            "hyperliquidChain": self.inner.chain.as_str(),
1891            "signatureChainId": self.inner.chain.signature_chain_id(),
1892            "token": token,
1893            "amount": amount.to_string(),
1894            "sourceDex": source_dex,
1895            "destinationRecipient": destination,
1896            "addressEncoding": "hex",
1897            "destinationChainId": destination_chain_id,
1898            "gasLimit": gas_limit,
1899            "data": data,
1900            "nonce": nonce,
1901        });
1902
1903        self.inner.build_sign_send(&action, None).await
1904    }
1905
1906    // ──────────────────────────────────────────────────────────────────────────
1907    // Additional Margin Operations
1908    // ──────────────────────────────────────────────────────────────────────────
1909
1910    /// Top up isolated-only margin to target a specific leverage
1911    pub async fn top_up_isolated_only_margin(
1912        &self,
1913        asset: &str,
1914        leverage: f64,
1915    ) -> Result<Value> {
1916        let asset_idx = self
1917            .inner
1918            .metadata
1919            .resolve_asset(asset)
1920            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1921
1922        let action = json!({
1923            "type": "topUpIsolatedOnlyMargin",
1924            "asset": asset_idx,
1925            "leverage": leverage.to_string(),
1926        });
1927
1928        self.inner.build_sign_send(&action, None).await
1929    }
1930
1931    // ──────────────────────────────────────────────────────────────────────────
1932    // Validator Operations
1933    // ──────────────────────────────────────────────────────────────────────────
1934
1935    /// Submit a validator vote for the risk-free rate (validator only)
1936    pub async fn validator_l1_stream(&self, risk_free_rate: &str) -> Result<Value> {
1937        let action = json!({
1938            "type": "validatorL1Stream",
1939            "riskFreeRate": risk_free_rate,
1940        });
1941
1942        self.inner.build_sign_send(&action, None).await
1943    }
1944
1945    // ──────────────────────────────────────────────────────────────────────────
1946    // Cancel Operations
1947    // ──────────────────────────────────────────────────────────────────────────
1948
1949    /// Cancel an order by client order ID (cloid)
1950    pub async fn cancel_by_cloid(&self, cloid: &str, asset: &str) -> Result<Value> {
1951        let asset_idx = self
1952            .inner
1953            .metadata
1954            .resolve_asset(asset)
1955            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1956
1957        let action = json!({
1958            "type": "cancelByCloid",
1959            "cancels": [{"asset": asset_idx, "cloid": cloid}],
1960        });
1961
1962        self.inner.build_sign_send(&action, None).await
1963    }
1964
1965    /// Schedule cancellation of all orders after a delay (dead-man's switch)
1966    pub async fn schedule_cancel(&self, time_ms: Option<u64>) -> Result<Value> {
1967        let mut action = json!({"type": "scheduleCancel"});
1968        if let Some(t) = time_ms {
1969            action["time"] = json!(t);
1970        }
1971        self.inner.build_sign_send(&action, None).await
1972    }
1973
1974    // ──────────────────────────────────────────────────────────────────────────
1975    // EVM Stream Access
1976    // ──────────────────────────────────────────────────────────────────────────
1977    // Convenience Queries
1978    // ──────────────────────────────────────────────────────────────────────────
1979
1980    /// Get mid price for an asset
1981    pub async fn get_mid(&self, asset: &str) -> Result<f64> {
1982        self.inner.get_mid_price(asset).await
1983    }
1984
1985    /// Force refresh of market metadata cache
1986    pub async fn refresh_markets(&self) -> Result<()> {
1987        self.inner.refresh_metadata().await
1988    }
1989}
1990
1991// ══════════════════════════════════════════════════════════════════════════════
1992// Market Order Builder
1993// ══════════════════════════════════════════════════════════════════════════════
1994
1995/// Builder for market orders with size or notional
1996pub struct MarketOrderBuilder {
1997    inner: Arc<HyperliquidSDKInner>,
1998    asset: String,
1999    side: Side,
2000    size: Option<f64>,
2001    notional: Option<f64>,
2002    slippage: Option<f64>,
2003    reduce_only: bool,
2004}
2005
2006impl MarketOrderBuilder {
2007    fn new(inner: Arc<HyperliquidSDKInner>, asset: String, side: Side) -> Self {
2008        Self {
2009            inner,
2010            asset,
2011            side,
2012            size: None,
2013            notional: None,
2014            slippage: None,
2015            reduce_only: false,
2016        }
2017    }
2018
2019    /// Set order size (in base asset units)
2020    pub fn size(mut self, size: f64) -> Self {
2021        self.size = Some(size);
2022        self
2023    }
2024
2025    /// Set notional value (in USD)
2026    pub fn notional(mut self, notional: f64) -> Self {
2027        self.notional = Some(notional);
2028        self
2029    }
2030
2031    /// Set per-call slippage override (default uses constructor-level slippage)
2032    ///
2033    /// Range: 0.001 (0.1%) to 0.1 (10%)
2034    pub fn slippage(mut self, slippage: f64) -> Self {
2035        self.slippage = Some(slippage);
2036        self
2037    }
2038
2039    /// Set reduce-only flag (only reduce existing position, never increase)
2040    pub fn reduce_only(mut self) -> Self {
2041        self.reduce_only = true;
2042        self
2043    }
2044
2045    /// Execute the market order
2046    ///
2047    /// Uses the human-readable format (`asset`, `side`, `size`, `tif: "market"`)
2048    /// and delegates price computation to the worker.
2049    pub async fn execute(self) -> Result<PlacedOrder> {
2050        // Get size decimals for rounding
2051        let sz_decimals = self.inner.metadata.get_asset(&self.asset)
2052            .map(|a| a.sz_decimals)
2053            .unwrap_or(5) as i32;
2054
2055        let size = if let Some(s) = self.size {
2056            s
2057        } else if let Some(notional) = self.notional {
2058            let mid = self.inner.get_mid_price(&self.asset).await?;
2059            notional / mid
2060        } else {
2061            return Err(Error::ValidationError(
2062                "Market order must have size or notional".to_string(),
2063            ));
2064        };
2065
2066        // Round size to allowed decimals
2067        let size_rounded = (size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals);
2068
2069        // Use human-readable format — worker computes price from mid + slippage
2070        let mut order_spec = json!({
2071            "asset": self.asset,
2072            "side": if self.side.is_buy() { "buy" } else { "sell" },
2073            "size": format!("{}", size_rounded),
2074            "tif": "market",
2075        });
2076        if self.reduce_only {
2077            order_spec["reduceOnly"] = json!(true);
2078        }
2079        let action = json!({
2080            "type": "order",
2081            "orders": [order_spec],
2082        });
2083
2084        let response = self.inner.build_sign_send(&action, self.slippage).await?;
2085
2086        Ok(PlacedOrder::from_response(
2087            response,
2088            self.asset,
2089            self.side,
2090            Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
2091            None,
2092            Some(self.inner),
2093        ))
2094    }
2095}
2096
2097// Implement await for MarketOrderBuilder
2098impl std::future::IntoFuture for MarketOrderBuilder {
2099    type Output = Result<PlacedOrder>;
2100    type IntoFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send>>;
2101
2102    fn into_future(self) -> Self::IntoFuture {
2103        Box::pin(self.execute())
2104    }
2105}