Skip to main content

quicknode_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) -> Result<BuildResponse> {
388        let url = self.exchange_url();
389
390        let body = json!({ "action": action });
391
392        let response = self
393            .http_client
394            .post(url)
395            .json(&body)
396            .send()
397            .await?;
398
399        let status = response.status();
400        let text = response.text().await?;
401
402        if !status.is_success() {
403            return Err(Error::NetworkError(format!(
404                "Build request failed {}: {}",
405                status, text
406            )));
407        }
408
409        let result: Value = serde_json::from_str(&text)?;
410
411        // Check for error
412        if let Some(error) = result.get("error") {
413            return Err(Error::from_api_error(
414                error.as_str().unwrap_or("Unknown error"),
415            ));
416        }
417
418        Ok(BuildResponse {
419            hash: result
420                .get("hash")
421                .and_then(|h| h.as_str())
422                .unwrap_or("")
423                .to_string(),
424            nonce: result.get("nonce").and_then(|n| n.as_u64()).unwrap_or(0),
425            action: result.get("action").cloned().unwrap_or(action.clone()),
426        })
427    }
428
429    /// Send a signed action
430    pub async fn send_action(
431        &self,
432        action: &Value,
433        nonce: u64,
434        signature: &Signature,
435    ) -> Result<Value> {
436        let url = self.exchange_url();
437
438        let body = json!({
439            "action": action,
440            "nonce": nonce,
441            "signature": signature,
442        });
443
444        let response = self
445            .http_client
446            .post(url)
447            .json(&body)
448            .send()
449            .await?;
450
451        let status = response.status();
452        let text = response.text().await?;
453
454        if !status.is_success() {
455            return Err(Error::NetworkError(format!(
456                "Send request failed {}: {}",
457                status, text
458            )));
459        }
460
461        let result: Value = serde_json::from_str(&text)?;
462
463        // Check for API error
464        if let Some(hl_status) = result.get("status") {
465            if hl_status.as_str() == Some("err") {
466                if let Some(response) = result.get("response") {
467                    let raw = response.as_str()
468                        .map(|s| s.to_string())
469                        .unwrap_or_else(|| response.to_string());
470                    return Err(Error::from_api_error(&raw));
471                }
472            }
473        }
474
475        Ok(result)
476    }
477
478    /// Build, sign, and send an action
479    pub async fn build_sign_send(&self, action: &Value) -> Result<Value> {
480        let signer = self
481            .signer
482            .as_ref()
483            .ok_or_else(|| Error::ConfigError("No private key configured".to_string()))?;
484
485        // Step 1: Build
486        let build_result = self.build_action(action).await?;
487
488        // Step 2: Sign
489        let hash_bytes = hex::decode(build_result.hash.trim_start_matches("0x"))
490            .map_err(|e| Error::SigningError(format!("Invalid hash: {}", e)))?;
491
492        let hash = alloy::primitives::B256::from_slice(&hash_bytes);
493        let signature = sign_hash(signer, hash).await?;
494
495        // Step 3: Send
496        self.send_action(&build_result.action, build_result.nonce, &signature)
497            .await
498    }
499
500    /// Refresh metadata cache
501    pub async fn refresh_metadata(&self) -> Result<()> {
502        // Fetch perp meta
503        let meta = self.query_info(&json!({"type": "meta"})).await?;
504
505        // Fetch spot meta
506        let spot_meta = self.query_info(&json!({"type": "spotMeta"})).await.ok();
507
508        // Fetch DEXes
509        let dexes_result = self.query_info(&json!({"type": "perpDexs"})).await.ok();
510        let dexes: Vec<String> = dexes_result
511            .and_then(|v| {
512                v.as_array().map(|arr| {
513                    arr.iter()
514                        .filter_map(|d| d.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
515                        .collect()
516                })
517            })
518            .unwrap_or_default();
519
520        self.metadata.update(&meta, spot_meta.as_ref(), &dexes);
521
522        Ok(())
523    }
524
525    /// Fetch all mid prices
526    pub async fn fetch_all_mids(&self) -> Result<HashMap<String, f64>> {
527        let result = self.query_info(&json!({"type": "allMids"})).await?;
528
529        let mut mids = HashMap::new();
530        if let Some(obj) = result.as_object() {
531            for (coin, price_val) in obj {
532                let price_str = price_val.as_str().unwrap_or("");
533                if let Ok(price) = price_str.parse::<f64>() {
534                    mids.insert(coin.clone(), price);
535                    self.mid_prices.insert(coin.clone(), price);
536                }
537            }
538        }
539
540        // Also fetch HIP-3 mids
541        for dex in self.metadata.get_dexes() {
542            if let Ok(dex_result) = self.query_info(&json!({"type": "allMids", "dex": dex})).await {
543                if let Some(obj) = dex_result.as_object() {
544                    for (coin, price_val) in obj {
545                        let price_str = price_val.as_str().unwrap_or("");
546                        if let Ok(price) = price_str.parse::<f64>() {
547                            mids.insert(coin.clone(), price);
548                            self.mid_prices.insert(coin.clone(), price);
549                        }
550                    }
551                }
552            }
553        }
554
555        Ok(mids)
556    }
557
558    /// Get mid price for an asset (from cache or fetch)
559    pub async fn get_mid_price(&self, asset: &str) -> Result<f64> {
560        if let Some(price) = self.mid_prices.get(asset) {
561            return Ok(*price);
562        }
563
564        // Fetch all mids
565        let mids = self.fetch_all_mids().await?;
566        mids.get(asset)
567            .copied()
568            .ok_or_else(|| Error::ValidationError(format!("No price found for {}", asset)))
569    }
570
571    /// Resolve asset name to index
572    pub fn resolve_asset(&self, name: &str) -> Option<usize> {
573        self.metadata.resolve_asset(name)
574    }
575
576    /// Cancel an order by OID
577    pub async fn cancel_by_oid(&self, oid: u64, asset: &str) -> Result<Value> {
578        let asset_index = self
579            .resolve_asset(asset)
580            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
581
582        let action = json!({
583            "type": "cancel",
584            "cancels": [{
585                "a": asset_index,
586                "o": oid,
587            }]
588        });
589
590        self.build_sign_send(&action).await
591    }
592
593    /// Modify an order by OID
594    pub async fn modify_by_oid(
595        &self,
596        oid: u64,
597        asset: &str,
598        side: Side,
599        price: Decimal,
600        size: Decimal,
601    ) -> Result<PlacedOrder> {
602        let asset_index = self
603            .resolve_asset(asset)
604            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
605
606        let action = json!({
607            "type": "batchModify",
608            "modifies": [{
609                "oid": oid,
610                "order": {
611                    "a": asset_index,
612                    "b": side.is_buy(),
613                    "p": price.normalize().to_string(),
614                    "s": size.normalize().to_string(),
615                    "r": false,
616                    "t": {"limit": {"tif": "Gtc"}},
617                    "c": "0x00000000000000000000000000000000",
618                }
619            }]
620        });
621
622        let response = self.build_sign_send(&action).await?;
623
624        Ok(PlacedOrder::from_response(
625            response,
626            asset.to_string(),
627            side,
628            size,
629            Some(price),
630            None,
631        ))
632    }
633}
634
635/// Build response from the server
636#[derive(Debug)]
637pub struct BuildResponse {
638    pub hash: String,
639    pub nonce: u64,
640    pub action: Value,
641}
642
643// ══════════════════════════════════════════════════════════════════════════════
644// SDK Builder
645// ══════════════════════════════════════════════════════════════════════════════
646
647/// Builder for HyperliquidSDK
648#[derive(Default)]
649pub struct HyperliquidSDKBuilder {
650    endpoint: Option<String>,
651    private_key: Option<String>,
652    testnet: bool,
653    auto_approve: bool,
654    max_fee: String,
655    slippage: f64,
656    timeout: Duration,
657}
658
659impl HyperliquidSDKBuilder {
660    /// Create a new builder
661    pub fn new() -> Self {
662        Self {
663            endpoint: None,
664            private_key: None,
665            testnet: false,
666            auto_approve: true,
667            max_fee: "1%".to_string(),
668            slippage: DEFAULT_SLIPPAGE,
669            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
670        }
671    }
672
673    /// Set the QuickNode endpoint
674    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
675        self.endpoint = Some(endpoint.into());
676        self
677    }
678
679    /// Set the private key
680    pub fn private_key(mut self, key: impl Into<String>) -> Self {
681        self.private_key = Some(key.into());
682        self
683    }
684
685    /// Use testnet
686    pub fn testnet(mut self, testnet: bool) -> Self {
687        self.testnet = testnet;
688        self
689    }
690
691    /// Auto-approve builder fee on first trade
692    pub fn auto_approve(mut self, auto: bool) -> Self {
693        self.auto_approve = auto;
694        self
695    }
696
697    /// Set maximum builder fee
698    pub fn max_fee(mut self, fee: impl Into<String>) -> Self {
699        self.max_fee = fee.into();
700        self
701    }
702
703    /// Set slippage for market orders
704    pub fn slippage(mut self, slippage: f64) -> Self {
705        self.slippage = slippage;
706        self
707    }
708
709    /// Set request timeout
710    pub fn timeout(mut self, timeout: Duration) -> Self {
711        self.timeout = timeout;
712        self
713    }
714
715    /// Build the SDK
716    pub async fn build(self) -> Result<HyperliquidSDK> {
717        // Get private key from builder or environment
718        let private_key = self
719            .private_key
720            .or_else(|| std::env::var("PRIVATE_KEY").ok());
721
722        // Parse signer if key provided
723        let (signer, address) = if let Some(key) = private_key {
724            let key = key.trim_start_matches("0x");
725            let signer = PrivateKeySigner::from_str(key)?;
726            let address = signer.address();
727            (Some(signer), Some(address))
728        } else {
729            (None, None)
730        };
731
732        // Build HTTP client
733        let http_client = Client::builder()
734            .timeout(self.timeout)
735            .build()
736            .map_err(|e| Error::ConfigError(format!("Failed to create HTTP client: {}", e)))?;
737
738        let chain = if self.testnet {
739            Chain::Testnet
740        } else {
741            Chain::Mainnet
742        };
743
744        // Parse endpoint info for URL routing
745        let endpoint_info = self.endpoint.as_ref().map(|ep| EndpointInfo::parse(ep));
746
747        let inner = Arc::new(HyperliquidSDKInner {
748            http_client,
749            signer,
750            address,
751            chain,
752            endpoint: self.endpoint,
753            endpoint_info,
754            slippage: self.slippage,
755            metadata: MetadataCache::default(),
756            mid_prices: DashMap::new(),
757        });
758
759        // Refresh metadata
760        if let Err(e) = inner.refresh_metadata().await {
761            tracing::warn!("Failed to fetch initial metadata: {}", e);
762        }
763
764        Ok(HyperliquidSDK {
765            inner,
766            auto_approve: self.auto_approve,
767            max_fee: self.max_fee,
768        })
769    }
770}
771
772// ══════════════════════════════════════════════════════════════════════════════
773// Main SDK
774// ══════════════════════════════════════════════════════════════════════════════
775
776/// Main Hyperliquid SDK client
777pub struct HyperliquidSDK {
778    inner: Arc<HyperliquidSDKInner>,
779    #[allow(dead_code)]
780    auto_approve: bool,
781    max_fee: String,
782}
783
784impl HyperliquidSDK {
785    /// Create a new SDK builder
786    pub fn new() -> HyperliquidSDKBuilder {
787        HyperliquidSDKBuilder::new()
788    }
789
790    /// Get the user's address
791    pub fn address(&self) -> Option<Address> {
792        self.inner.address
793    }
794
795    /// Get the chain
796    pub fn chain(&self) -> Chain {
797        self.inner.chain
798    }
799
800    // ──────────────────────────────────────────────────────────────────────────
801    // Info API (lazy accessor)
802    // ──────────────────────────────────────────────────────────────────────────
803
804    /// Access the Info API
805    pub fn info(&self) -> crate::info::Info {
806        crate::info::Info::new(self.inner.clone())
807    }
808
809    /// Access the HyperCore API
810    pub fn core(&self) -> crate::hypercore::HyperCore {
811        crate::hypercore::HyperCore::new(self.inner.clone())
812    }
813
814    /// Access the EVM API
815    pub fn evm(&self) -> crate::evm::EVM {
816        crate::evm::EVM::new(self.inner.clone())
817    }
818
819    /// Create a WebSocket stream
820    pub fn stream(&self) -> crate::stream::Stream {
821        crate::stream::Stream::new(self.inner.endpoint.clone())
822    }
823
824    /// Create a gRPC stream
825    pub fn grpc(&self) -> crate::grpc::GRPCStream {
826        crate::grpc::GRPCStream::new(self.inner.endpoint.clone())
827    }
828
829    /// Access the EVM WebSocket stream
830    pub fn evm_stream(&self) -> crate::evm_stream::EVMStream {
831        crate::evm_stream::EVMStream::new(self.inner.endpoint.clone())
832    }
833
834    // ──────────────────────────────────────────────────────────────────────────
835    // Quick Queries
836    // ──────────────────────────────────────────────────────────────────────────
837
838    /// Get all available markets
839    pub async fn markets(&self) -> Result<Value> {
840        self.inner.query_info(&json!({"type": "meta"})).await
841    }
842
843    /// Get all DEXes (HIP-3)
844    pub async fn dexes(&self) -> Result<Value> {
845        self.inner.query_info(&json!({"type": "perpDexs"})).await
846    }
847
848    /// Get open orders for the current user
849    pub async fn open_orders(&self) -> Result<Value> {
850        let address = self
851            .inner
852            .address
853            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
854
855        self.inner
856            .query_info(&json!({
857                "type": "openOrders",
858                "user": format!("{:?}", address),
859            }))
860            .await
861    }
862
863    /// Get status of a specific order
864    pub async fn order_status(&self, oid: u64, dex: Option<&str>) -> Result<Value> {
865        let address = self
866            .inner
867            .address
868            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
869
870        let mut req = json!({
871            "type": "orderStatus",
872            "user": format!("{:?}", address),
873            "oid": oid,
874        });
875
876        if let Some(d) = dex {
877            req["dex"] = json!(d);
878        }
879
880        self.inner.query_info(&req).await
881    }
882
883    // ──────────────────────────────────────────────────────────────────────────
884    // Order Placement
885    // ──────────────────────────────────────────────────────────────────────────
886
887    /// Place a market buy order
888    pub async fn market_buy(&self, asset: &str) -> MarketOrderBuilder {
889        MarketOrderBuilder::new(self.inner.clone(), asset.to_string(), Side::Buy)
890    }
891
892    /// Place a market sell order
893    pub async fn market_sell(&self, asset: &str) -> MarketOrderBuilder {
894        MarketOrderBuilder::new(self.inner.clone(), asset.to_string(), Side::Sell)
895    }
896
897    /// Place a limit buy order
898    pub async fn buy(
899        &self,
900        asset: &str,
901        size: f64,
902        price: f64,
903        tif: TIF,
904    ) -> Result<PlacedOrder> {
905        self.place_order(asset, Side::Buy, size, Some(price), tif, false)
906            .await
907    }
908
909    /// Place a limit sell order
910    pub async fn sell(
911        &self,
912        asset: &str,
913        size: f64,
914        price: f64,
915        tif: TIF,
916    ) -> Result<PlacedOrder> {
917        self.place_order(asset, Side::Sell, size, Some(price), tif, false)
918            .await
919    }
920
921    /// Place an order using the fluent builder
922    pub async fn order(&self, order: Order) -> Result<PlacedOrder> {
923        order.validate()?;
924
925        let asset = order.get_asset();
926        let side = order.get_side();
927        let tif = order.get_tif();
928
929        // Resolve size from notional if needed
930        let size = if let Some(s) = order.get_size() {
931            s
932        } else if let Some(notional) = order.get_notional() {
933            let mid = self.inner.get_mid_price(asset).await?;
934            Decimal::from_f64_retain(notional.to_string().parse::<f64>().unwrap_or(0.0) / mid)
935                .unwrap_or_default()
936        } else {
937            return Err(Error::ValidationError(
938                "Order must have size or notional".to_string(),
939            ));
940        };
941
942        // Resolve price
943        let price = if order.is_market() {
944            let mid = self.inner.get_mid_price(asset).await?;
945            let slippage = self.inner.slippage;
946            let price = if side.is_buy() {
947                mid * (1.0 + slippage)
948            } else {
949                mid * (1.0 - slippage)
950            };
951            Some(price)
952        } else {
953            order
954                .get_price()
955                .map(|p| p.to_string().parse::<f64>().unwrap_or(0.0))
956        };
957
958        self.place_order(
959            asset,
960            side,
961            size.to_string().parse::<f64>().unwrap_or(0.0),
962            price,
963            if order.is_market() { TIF::Ioc } else { tif },
964            order.is_reduce_only(),
965        )
966        .await
967    }
968
969    /// Place a trigger order (stop-loss / take-profit)
970    pub async fn trigger_order(&self, order: TriggerOrder) -> Result<PlacedOrder> {
971        order.validate()?;
972
973        let asset = order.get_asset();
974        let asset_index = self
975            .inner
976            .resolve_asset(asset)
977            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
978
979        // Get size decimals for rounding
980        let sz_decimals = self.inner.metadata.get_asset(asset)
981            .map(|a| a.sz_decimals)
982            .unwrap_or(5) as u32;
983
984        let trigger_px = order
985            .get_trigger_price()
986            .ok_or_else(|| Error::ValidationError("Trigger price required".to_string()))?;
987
988        let size = order
989            .get_size()
990            .ok_or_else(|| Error::ValidationError("Size required".to_string()))?;
991
992        // Round size to allowed decimals
993        let size_rounded = size.round_dp(sz_decimals);
994
995        // Get execution price, rounded to valid tick
996        let limit_px = if order.is_market() {
997            let mid = self.inner.get_mid_price(asset).await?;
998            let slippage = self.inner.slippage;
999            let price = if order.get_side().is_buy() {
1000                mid * (1.0 + slippage)
1001            } else {
1002                mid * (1.0 - slippage)
1003            };
1004            Decimal::from_f64_retain(price.round()).unwrap_or_default()
1005        } else {
1006            order.get_limit_price().unwrap_or(trigger_px).round()
1007        };
1008
1009        // Round trigger price
1010        let trigger_px_rounded = trigger_px.round();
1011
1012        // Generate random cloid (Hyperliquid requires nonzero cloid)
1013        let cloid = {
1014            let now = std::time::SystemTime::now()
1015                .duration_since(std::time::UNIX_EPOCH)
1016                .unwrap_or_default();
1017            let nanos = now.as_nanos() as u64;
1018            let hi = nanos.wrapping_mul(0x517cc1b727220a95);
1019            format!("0x{:016x}{:016x}", nanos, hi)
1020        };
1021
1022        let action = json!({
1023            "type": "order",
1024            "orders": [{
1025                "a": asset_index,
1026                "b": order.get_side().is_buy(),
1027                "p": limit_px.normalize().to_string(),
1028                "s": size_rounded.normalize().to_string(),
1029                "r": order.is_reduce_only(),
1030                "t": {
1031                    "trigger": {
1032                        "isMarket": order.is_market(),
1033                        "triggerPx": trigger_px_rounded.normalize().to_string(),
1034                        "tpsl": order.get_tpsl().to_string(),
1035                    }
1036                },
1037                "c": cloid,
1038            }],
1039            "grouping": "na",
1040        });
1041
1042        let response = self.inner.build_sign_send(&action).await?;
1043
1044        Ok(PlacedOrder::from_response(
1045            response,
1046            asset.to_string(),
1047            order.get_side(),
1048            size,
1049            Some(limit_px),
1050            Some(self.inner.clone()),
1051        ))
1052    }
1053
1054    /// Stop-loss helper
1055    pub async fn stop_loss(
1056        &self,
1057        asset: &str,
1058        size: f64,
1059        trigger_price: f64,
1060    ) -> Result<PlacedOrder> {
1061        self.trigger_order(
1062            TriggerOrder::stop_loss(asset)
1063                .size(size)
1064                .trigger_price(trigger_price)
1065                .market(),
1066        )
1067        .await
1068    }
1069
1070    /// Take-profit helper
1071    pub async fn take_profit(
1072        &self,
1073        asset: &str,
1074        size: f64,
1075        trigger_price: f64,
1076    ) -> Result<PlacedOrder> {
1077        self.trigger_order(
1078            TriggerOrder::take_profit(asset)
1079                .size(size)
1080                .trigger_price(trigger_price)
1081                .market(),
1082        )
1083        .await
1084    }
1085
1086    /// Internal order placement
1087    async fn place_order(
1088        &self,
1089        asset: &str,
1090        side: Side,
1091        size: f64,
1092        price: Option<f64>,
1093        tif: TIF,
1094        reduce_only: bool,
1095    ) -> Result<PlacedOrder> {
1096        let asset_index = self
1097            .inner
1098            .resolve_asset(asset)
1099            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1100
1101        // Get size decimals for rounding
1102        let sz_decimals = self.inner.metadata.get_asset(asset)
1103            .map(|a| a.sz_decimals)
1104            .unwrap_or(5) as i32;
1105
1106        // Round size to allowed decimals
1107        let size_rounded = (size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals);
1108
1109        // Round price to valid tick (integer for most markets)
1110        let resolved_price = price.map(|p| p.round()).unwrap_or(0.0);
1111
1112        let tif_wire = match tif {
1113            TIF::Ioc => "Ioc",
1114            TIF::Gtc => "Gtc",
1115            TIF::Alo => "Alo",
1116            TIF::Market => "Ioc",
1117        };
1118
1119        // Generate random cloid (Hyperliquid requires nonzero cloid)
1120        let cloid = {
1121            let now = std::time::SystemTime::now()
1122                .duration_since(std::time::UNIX_EPOCH)
1123                .unwrap_or_default();
1124            let nanos = now.as_nanos() as u64;
1125            let hi = nanos.wrapping_mul(0x517cc1b727220a95);
1126            format!("0x{:016x}{:016x}", nanos, hi)
1127        };
1128
1129        let action = json!({
1130            "type": "order",
1131            "orders": [{
1132                "a": asset_index,
1133                "b": side.is_buy(),
1134                "p": format!("{}", resolved_price),
1135                "s": format!("{}", size_rounded),
1136                "r": reduce_only,
1137                "t": {"limit": {"tif": tif_wire}},
1138                "c": cloid,
1139            }],
1140            "grouping": "na",
1141        });
1142
1143        let response = self.inner.build_sign_send(&action).await?;
1144
1145        Ok(PlacedOrder::from_response(
1146            response,
1147            asset.to_string(),
1148            side,
1149            Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
1150            price.map(|p| Decimal::from_f64_retain(p).unwrap_or_default()),
1151            Some(self.inner.clone()),
1152        ))
1153    }
1154
1155    // ──────────────────────────────────────────────────────────────────────────
1156    // Order Management
1157    // ──────────────────────────────────────────────────────────────────────────
1158
1159    /// Modify an existing order
1160    ///
1161    /// The order is identified by OID, which is included in the returned order.
1162    pub async fn modify(
1163        &self,
1164        oid: u64,
1165        asset: &str,
1166        is_buy: bool,
1167        size: f64,
1168        price: f64,
1169        tif: TIF,
1170        reduce_only: bool,
1171        cloid: Option<&str>,
1172    ) -> Result<PlacedOrder> {
1173        let asset_idx = self
1174            .inner
1175            .metadata
1176            .resolve_asset(asset)
1177            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1178
1179        let sz_decimals = self.inner.metadata.get_asset(asset)
1180            .map(|a| a.sz_decimals)
1181            .unwrap_or(8) as i32;
1182        let size_rounded = (size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals);
1183
1184        let order_type = match tif {
1185            TIF::Gtc => json!({"limit": {"tif": "Gtc"}}),
1186            TIF::Ioc | TIF::Market => json!({"limit": {"tif": "Ioc"}}),
1187            TIF::Alo => json!({"limit": {"tif": "Alo"}}),
1188        };
1189
1190        let cloid_val = cloid
1191            .map(|s| s.to_string())
1192            .unwrap_or_else(|| {
1193                let now = std::time::SystemTime::now()
1194                    .duration_since(std::time::UNIX_EPOCH)
1195                    .unwrap_or_default();
1196                let nanos = now.as_nanos() as u64;
1197                let hi = nanos.wrapping_mul(0x517cc1b727220a95);
1198                format!("0x{:016x}{:016x}", nanos, hi)
1199            });
1200
1201        let action = json!({
1202            "type": "batchModify",
1203            "modifies": [{
1204                "oid": oid,
1205                "order": {
1206                    "a": asset_idx,
1207                    "b": is_buy,
1208                    "p": format!("{:.8}", price).trim_end_matches('0').trim_end_matches('.'),
1209                    "s": format!("{:.8}", size_rounded).trim_end_matches('0').trim_end_matches('.'),
1210                    "r": reduce_only,
1211                    "t": order_type,
1212                    "c": cloid_val,
1213                }
1214            }]
1215        });
1216
1217        let response = self.inner.build_sign_send(&action).await?;
1218
1219        Ok(PlacedOrder::from_response(
1220            response,
1221            asset.to_string(),
1222            if is_buy { Side::Buy } else { Side::Sell },
1223            Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
1224            Some(Decimal::from_f64_retain(price).unwrap_or_default()),
1225            Some(self.inner.clone()),
1226        ))
1227    }
1228
1229    /// Cancel an order by OID
1230    pub async fn cancel(&self, oid: u64, asset: &str) -> Result<Value> {
1231        self.inner.cancel_by_oid(oid, asset).await
1232    }
1233
1234    /// Cancel all orders (optionally for a specific asset)
1235    pub async fn cancel_all(&self, asset: Option<&str>) -> Result<Value> {
1236        // Ensure we have an address configured
1237        if self.inner.address.is_none() {
1238            return Err(Error::ConfigError("No address configured".to_string()));
1239        }
1240
1241        // Get open orders
1242        let open_orders = self.open_orders().await?;
1243
1244        let cancels: Vec<Value> = open_orders
1245            .as_array()
1246            .unwrap_or(&vec![])
1247            .iter()
1248            .filter(|order| {
1249                if let Some(asset) = asset {
1250                    order.get("coin").and_then(|c| c.as_str()) == Some(asset)
1251                } else {
1252                    true
1253                }
1254            })
1255            .filter_map(|order| {
1256                let oid = order.get("oid").and_then(|o| o.as_u64())?;
1257                let coin = order.get("coin").and_then(|c| c.as_str())?;
1258                let asset_index = self.inner.resolve_asset(coin)?;
1259                Some(json!({"a": asset_index, "o": oid}))
1260            })
1261            .collect();
1262
1263        if cancels.is_empty() {
1264            return Ok(json!({"status": "ok", "message": "No orders to cancel"}));
1265        }
1266
1267        let action = json!({
1268            "type": "cancel",
1269            "cancels": cancels,
1270        });
1271
1272        self.inner.build_sign_send(&action).await
1273    }
1274
1275    /// Close position for an asset
1276    pub async fn close_position(&self, asset: &str) -> Result<PlacedOrder> {
1277        let address = self
1278            .inner
1279            .address
1280            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
1281
1282        // Get position
1283        let state = self
1284            .inner
1285            .query_info(&json!({
1286                "type": "clearinghouseState",
1287                "user": format!("{:?}", address),
1288            }))
1289            .await?;
1290
1291        // Find position for asset
1292        let positions = state
1293            .get("assetPositions")
1294            .and_then(|p| p.as_array())
1295            .ok_or_else(|| Error::NoPosition {
1296                asset: asset.to_string(),
1297            })?;
1298
1299        let position = positions
1300            .iter()
1301            .find(|p| {
1302                p.get("position")
1303                    .and_then(|pos| pos.get("coin"))
1304                    .and_then(|c| c.as_str())
1305                    == Some(asset)
1306            })
1307            .ok_or_else(|| Error::NoPosition {
1308                asset: asset.to_string(),
1309            })?;
1310
1311        let szi = position
1312            .get("position")
1313            .and_then(|p| p.get("szi"))
1314            .and_then(|s| s.as_str())
1315            .and_then(|s| s.parse::<f64>().ok())
1316            .ok_or_else(|| Error::NoPosition {
1317                asset: asset.to_string(),
1318            })?;
1319
1320        if szi.abs() < 1e-10 {
1321            return Err(Error::NoPosition {
1322                asset: asset.to_string(),
1323            });
1324        }
1325
1326        // Place opposite order
1327        let side = if szi > 0.0 { Side::Sell } else { Side::Buy };
1328
1329        // Get market price with slippage
1330        let mid = self.inner.get_mid_price(asset).await?;
1331        let price = if side.is_buy() {
1332            mid * (1.0 + self.inner.slippage)
1333        } else {
1334            mid * (1.0 - self.inner.slippage)
1335        };
1336
1337        self.place_order(asset, side, szi.abs(), Some(price), TIF::Ioc, true)
1338            .await
1339    }
1340
1341    // ──────────────────────────────────────────────────────────────────────────
1342    // Leverage & Margin
1343    // ──────────────────────────────────────────────────────────────────────────
1344
1345    /// Update leverage for an asset
1346    pub async fn update_leverage(
1347        &self,
1348        asset: &str,
1349        leverage: i32,
1350        is_cross: bool,
1351    ) -> Result<Value> {
1352        let asset_index = self
1353            .inner
1354            .resolve_asset(asset)
1355            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1356
1357        let action = json!({
1358            "type": "updateLeverage",
1359            "asset": asset_index,
1360            "isCross": is_cross,
1361            "leverage": leverage,
1362        });
1363
1364        self.inner.build_sign_send(&action).await
1365    }
1366
1367    /// Update isolated margin
1368    pub async fn update_isolated_margin(
1369        &self,
1370        asset: &str,
1371        is_buy: bool,
1372        amount_usd: f64,
1373    ) -> Result<Value> {
1374        let asset_index = self
1375            .inner
1376            .resolve_asset(asset)
1377            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1378
1379        let action = json!({
1380            "type": "updateIsolatedMargin",
1381            "asset": asset_index,
1382            "isBuy": is_buy,
1383            "ntli": (amount_usd * 1_000_000.0) as i64, // Convert to USDC with 6 decimals
1384        });
1385
1386        self.inner.build_sign_send(&action).await
1387    }
1388
1389    // ──────────────────────────────────────────────────────────────────────────
1390    // TWAP Orders
1391    // ──────────────────────────────────────────────────────────────────────────
1392
1393    /// Place a TWAP order
1394    pub async fn twap_order(
1395        &self,
1396        asset: &str,
1397        size: f64,
1398        is_buy: bool,
1399        duration_minutes: i64,
1400        reduce_only: bool,
1401        randomize: bool,
1402    ) -> Result<Value> {
1403        let action = json!({
1404            "type": "twapOrder",
1405            "twap": {
1406                "a": asset,
1407                "b": is_buy,
1408                "s": format!("{}", size),
1409                "r": reduce_only,
1410                "m": duration_minutes,
1411                "t": randomize,
1412            }
1413        });
1414
1415        self.inner.build_sign_send(&action).await
1416    }
1417
1418    /// Cancel a TWAP order
1419    pub async fn twap_cancel(&self, asset: &str, twap_id: i64) -> Result<Value> {
1420        let action = json!({
1421            "type": "twapCancel",
1422            "a": asset,
1423            "t": twap_id,
1424        });
1425
1426        self.inner.build_sign_send(&action).await
1427    }
1428
1429    // ──────────────────────────────────────────────────────────────────────────
1430    // Transfers
1431    // ──────────────────────────────────────────────────────────────────────────
1432
1433    /// Transfer USD to another address
1434    pub async fn transfer_usd(&self, destination: &str, amount: f64) -> Result<Value> {
1435        let time = SystemTime::now()
1436            .duration_since(UNIX_EPOCH)
1437            .unwrap()
1438            .as_millis() as u64;
1439
1440        let action = json!({
1441            "type": "usdSend",
1442            "hyperliquidChain": self.inner.chain.to_string(),
1443            "signatureChainId": self.inner.chain.signature_chain_id(),
1444            "destination": destination,
1445            "amount": format!("{}", amount),
1446            "time": time,
1447        });
1448
1449        self.inner.build_sign_send(&action).await
1450    }
1451
1452    /// Transfer spot token to another address
1453    pub async fn transfer_spot(
1454        &self,
1455        token: &str,
1456        destination: &str,
1457        amount: f64,
1458    ) -> Result<Value> {
1459        let time = SystemTime::now()
1460            .duration_since(UNIX_EPOCH)
1461            .unwrap()
1462            .as_millis() as u64;
1463
1464        let action = json!({
1465            "type": "spotSend",
1466            "hyperliquidChain": self.inner.chain.to_string(),
1467            "signatureChainId": self.inner.chain.signature_chain_id(),
1468            "token": token,
1469            "destination": destination,
1470            "amount": format!("{}", amount),
1471            "time": time,
1472        });
1473
1474        self.inner.build_sign_send(&action).await
1475    }
1476
1477    /// Withdraw to Arbitrum
1478    pub async fn withdraw(&self, amount: f64, destination: Option<&str>) -> Result<Value> {
1479        let time = SystemTime::now()
1480            .duration_since(UNIX_EPOCH)
1481            .unwrap()
1482            .as_millis() as u64;
1483
1484        let dest = destination
1485            .map(|s| s.to_string())
1486            .or_else(|| self.inner.address.map(|a| format!("{:?}", a)))
1487            .ok_or_else(|| Error::ConfigError("No destination address".to_string()))?;
1488
1489        let action = json!({
1490            "type": "withdraw3",
1491            "hyperliquidChain": self.inner.chain.to_string(),
1492            "signatureChainId": self.inner.chain.signature_chain_id(),
1493            "destination": dest,
1494            "amount": format!("{}", amount),
1495            "time": time,
1496        });
1497
1498        self.inner.build_sign_send(&action).await
1499    }
1500
1501    /// Transfer spot balance to perp balance
1502    pub async fn transfer_spot_to_perp(&self, amount: f64) -> Result<Value> {
1503        let nonce = SystemTime::now()
1504            .duration_since(UNIX_EPOCH)
1505            .unwrap()
1506            .as_millis() as u64;
1507
1508        let action = json!({
1509            "type": "usdClassTransfer",
1510            "hyperliquidChain": self.inner.chain.to_string(),
1511            "signatureChainId": self.inner.chain.signature_chain_id(),
1512            "amount": format!("{}", amount),
1513            "toPerp": true,
1514            "nonce": nonce,
1515        });
1516
1517        self.inner.build_sign_send(&action).await
1518    }
1519
1520    /// Transfer perp balance to spot balance
1521    pub async fn transfer_perp_to_spot(&self, amount: f64) -> Result<Value> {
1522        let nonce = SystemTime::now()
1523            .duration_since(UNIX_EPOCH)
1524            .unwrap()
1525            .as_millis() as u64;
1526
1527        let action = json!({
1528            "type": "usdClassTransfer",
1529            "hyperliquidChain": self.inner.chain.to_string(),
1530            "signatureChainId": self.inner.chain.signature_chain_id(),
1531            "amount": format!("{}", amount),
1532            "toPerp": false,
1533            "nonce": nonce,
1534        });
1535
1536        self.inner.build_sign_send(&action).await
1537    }
1538
1539    // ──────────────────────────────────────────────────────────────────────────
1540    // Vaults
1541    // ──────────────────────────────────────────────────────────────────────────
1542
1543    /// Deposit to a vault
1544    pub async fn vault_deposit(&self, vault_address: &str, amount: f64) -> Result<Value> {
1545        let action = json!({
1546            "type": "vaultTransfer",
1547            "vaultAddress": vault_address,
1548            "isDeposit": true,
1549            "usd": amount,
1550        });
1551
1552        self.inner.build_sign_send(&action).await
1553    }
1554
1555    /// Withdraw from a vault
1556    pub async fn vault_withdraw(&self, vault_address: &str, amount: f64) -> Result<Value> {
1557        let action = json!({
1558            "type": "vaultTransfer",
1559            "vaultAddress": vault_address,
1560            "isDeposit": false,
1561            "usd": amount,
1562        });
1563
1564        self.inner.build_sign_send(&action).await
1565    }
1566
1567    // ──────────────────────────────────────────────────────────────────────────
1568    // Staking
1569    // ──────────────────────────────────────────────────────────────────────────
1570
1571    /// Stake tokens
1572    pub async fn stake(&self, amount_tokens: f64) -> Result<Value> {
1573        let nonce = SystemTime::now()
1574            .duration_since(UNIX_EPOCH)
1575            .unwrap()
1576            .as_millis() as u64;
1577
1578        let wei = (amount_tokens * 1e18) as u128;
1579
1580        let action = json!({
1581            "type": "cDeposit",
1582            "hyperliquidChain": self.inner.chain.to_string(),
1583            "signatureChainId": self.inner.chain.signature_chain_id(),
1584            "wei": wei.to_string(),
1585            "nonce": nonce,
1586        });
1587
1588        self.inner.build_sign_send(&action).await
1589    }
1590
1591    /// Unstake tokens
1592    pub async fn unstake(&self, amount_tokens: f64) -> Result<Value> {
1593        let nonce = SystemTime::now()
1594            .duration_since(UNIX_EPOCH)
1595            .unwrap()
1596            .as_millis() as u64;
1597
1598        let wei = (amount_tokens * 1e18) as u128;
1599
1600        let action = json!({
1601            "type": "cWithdraw",
1602            "hyperliquidChain": self.inner.chain.to_string(),
1603            "signatureChainId": self.inner.chain.signature_chain_id(),
1604            "wei": wei.to_string(),
1605            "nonce": nonce,
1606        });
1607
1608        self.inner.build_sign_send(&action).await
1609    }
1610
1611    /// Delegate tokens to a validator
1612    pub async fn delegate(&self, validator: &str, amount_tokens: f64) -> Result<Value> {
1613        let nonce = SystemTime::now()
1614            .duration_since(UNIX_EPOCH)
1615            .unwrap()
1616            .as_millis() as u64;
1617
1618        let wei = (amount_tokens * 1e18) as u128;
1619
1620        let action = json!({
1621            "type": "tokenDelegate",
1622            "hyperliquidChain": self.inner.chain.to_string(),
1623            "signatureChainId": self.inner.chain.signature_chain_id(),
1624            "validator": validator,
1625            "isUndelegate": false,
1626            "wei": wei.to_string(),
1627            "nonce": nonce,
1628        });
1629
1630        self.inner.build_sign_send(&action).await
1631    }
1632
1633    /// Undelegate tokens from a validator
1634    pub async fn undelegate(&self, validator: &str, amount_tokens: f64) -> Result<Value> {
1635        let nonce = SystemTime::now()
1636            .duration_since(UNIX_EPOCH)
1637            .unwrap()
1638            .as_millis() as u64;
1639
1640        let wei = (amount_tokens * 1e18) as u128;
1641
1642        let action = json!({
1643            "type": "tokenDelegate",
1644            "hyperliquidChain": self.inner.chain.to_string(),
1645            "signatureChainId": self.inner.chain.signature_chain_id(),
1646            "validator": validator,
1647            "isUndelegate": true,
1648            "wei": wei.to_string(),
1649            "nonce": nonce,
1650        });
1651
1652        self.inner.build_sign_send(&action).await
1653    }
1654
1655    // ──────────────────────────────────────────────────────────────────────────
1656    // Builder Fee Approval
1657    // ──────────────────────────────────────────────────────────────────────────
1658
1659    /// Approve builder fee
1660    pub async fn approve_builder_fee(&self, max_fee: Option<&str>) -> Result<Value> {
1661        let fee = max_fee.unwrap_or(&self.max_fee);
1662
1663        let action = json!({
1664            "type": "approveBuilderFee",
1665            "maxFeeRate": fee,
1666        });
1667
1668        self.inner.build_sign_send(&action).await
1669    }
1670
1671    /// Revoke builder fee approval
1672    pub async fn revoke_builder_fee(&self) -> Result<Value> {
1673        self.approve_builder_fee(Some("0%")).await
1674    }
1675
1676    /// Check approval status
1677    pub async fn approval_status(&self) -> Result<Value> {
1678        let address = self
1679            .inner
1680            .address
1681            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
1682
1683        // Use the worker's /approval endpoint
1684        let url = format!("{}/approval", DEFAULT_WORKER_URL);
1685
1686        let response = self
1687            .inner
1688            .http_client
1689            .post(&url)
1690            .json(&json!({"user": format!("{:?}", address)}))
1691            .send()
1692            .await?;
1693
1694        let text = response.text().await?;
1695        serde_json::from_str(&text).map_err(|e| Error::JsonError(e.to_string()))
1696    }
1697
1698    // ──────────────────────────────────────────────────────────────────────────
1699    // Misc
1700    // ──────────────────────────────────────────────────────────────────────────
1701
1702    /// Reserve request weight (purchase rate limit capacity)
1703    pub async fn reserve_request_weight(&self, weight: i32) -> Result<Value> {
1704        let action = json!({
1705            "type": "reserveRequestWeight",
1706            "weight": weight,
1707        });
1708
1709        self.inner.build_sign_send(&action).await
1710    }
1711
1712    /// No-op (consume nonce)
1713    pub async fn noop(&self) -> Result<Value> {
1714        let action = json!({"type": "noop"});
1715        self.inner.build_sign_send(&action).await
1716    }
1717
1718    /// Preflight validation
1719    pub async fn preflight(
1720        &self,
1721        asset: &str,
1722        side: Side,
1723        price: f64,
1724        size: f64,
1725    ) -> Result<Value> {
1726        let url = format!("{}/preflight", DEFAULT_WORKER_URL);
1727
1728        let body = json!({
1729            "asset": asset,
1730            "side": side.to_string(),
1731            "price": price,
1732            "size": size,
1733        });
1734
1735        let response = self
1736            .inner
1737            .http_client
1738            .post(&url)
1739            .json(&body)
1740            .send()
1741            .await?;
1742
1743        let text = response.text().await?;
1744        serde_json::from_str(&text).map_err(|e| Error::JsonError(e.to_string()))
1745    }
1746
1747    // ──────────────────────────────────────────────────────────────────────────
1748    // Agent/API Key Management
1749    // ──────────────────────────────────────────────────────────────────────────
1750
1751    /// Approve an agent (API wallet) to trade on your behalf
1752    pub async fn approve_agent(
1753        &self,
1754        agent_address: &str,
1755        name: Option<&str>,
1756    ) -> Result<Value> {
1757        let nonce = SystemTime::now()
1758            .duration_since(UNIX_EPOCH)
1759            .unwrap()
1760            .as_millis() as u64;
1761
1762        let action = json!({
1763            "type": "approveAgent",
1764            "hyperliquidChain": self.inner.chain.as_str(),
1765            "signatureChainId": self.inner.chain.signature_chain_id(),
1766            "agentAddress": agent_address,
1767            "agentName": name,
1768            "nonce": nonce,
1769        });
1770
1771        self.inner.build_sign_send(&action).await
1772    }
1773
1774    // ──────────────────────────────────────────────────────────────────────────
1775    // Account Abstraction
1776    // ──────────────────────────────────────────────────────────────────────────
1777
1778    /// Set account abstraction mode
1779    ///
1780    /// Mode can be: "disabled", "unifiedAccount", or "portfolioMargin"
1781    pub async fn set_abstraction(&self, mode: &str, user: Option<&str>) -> Result<Value> {
1782        let address = self
1783            .inner
1784            .address
1785            .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
1786
1787        let addr_string = format!("{:?}", address);
1788        let user_addr = user.unwrap_or(&addr_string);
1789        let nonce = SystemTime::now()
1790            .duration_since(UNIX_EPOCH)
1791            .unwrap()
1792            .as_millis() as u64;
1793
1794        let action = json!({
1795            "type": "userSetAbstraction",
1796            "hyperliquidChain": self.inner.chain.as_str(),
1797            "signatureChainId": self.inner.chain.signature_chain_id(),
1798            "user": user_addr,
1799            "abstraction": mode,
1800            "nonce": nonce,
1801        });
1802
1803        self.inner.build_sign_send(&action).await
1804    }
1805
1806    /// Set account abstraction mode as an agent
1807    pub async fn agent_set_abstraction(&self, mode: &str) -> Result<Value> {
1808        // Map full mode names to short codes
1809        let short_mode = match mode {
1810            "disabled" | "i" => "i",
1811            "unifiedAccount" | "u" => "u",
1812            "portfolioMargin" | "p" => "p",
1813            _ => {
1814                return Err(Error::ValidationError(format!(
1815                    "Invalid mode: {}. Use 'disabled', 'unifiedAccount', or 'portfolioMargin'",
1816                    mode
1817                )))
1818            }
1819        };
1820
1821        let action = json!({
1822            "type": "agentSetAbstraction",
1823            "abstraction": short_mode,
1824        });
1825
1826        self.inner.build_sign_send(&action).await
1827    }
1828
1829    // ──────────────────────────────────────────────────────────────────────────
1830    // Advanced Transfers
1831    // ──────────────────────────────────────────────────────────────────────────
1832
1833    /// Generalized asset transfer between DEXs and accounts
1834    pub async fn send_asset(
1835        &self,
1836        token: &str,
1837        amount: f64,
1838        destination: &str,
1839        source_dex: Option<&str>,
1840        destination_dex: Option<&str>,
1841        from_sub_account: Option<&str>,
1842    ) -> Result<Value> {
1843        let nonce = SystemTime::now()
1844            .duration_since(UNIX_EPOCH)
1845            .unwrap()
1846            .as_millis() as u64;
1847
1848        let action = json!({
1849            "type": "sendAsset",
1850            "hyperliquidChain": self.inner.chain.as_str(),
1851            "signatureChainId": self.inner.chain.signature_chain_id(),
1852            "destination": destination,
1853            "sourceDex": source_dex.unwrap_or(""),
1854            "destinationDex": destination_dex.unwrap_or(""),
1855            "token": token,
1856            "amount": amount.to_string(),
1857            "fromSubAccount": from_sub_account.unwrap_or(""),
1858            "nonce": nonce,
1859        });
1860
1861        self.inner.build_sign_send(&action).await
1862    }
1863
1864    /// Transfer tokens to HyperEVM with custom data payload
1865    pub async fn send_to_evm_with_data(
1866        &self,
1867        token: &str,
1868        amount: f64,
1869        destination: &str,
1870        data: &str,
1871        source_dex: &str,
1872        destination_chain_id: u32,
1873        gas_limit: u64,
1874    ) -> Result<Value> {
1875        let nonce = SystemTime::now()
1876            .duration_since(UNIX_EPOCH)
1877            .unwrap()
1878            .as_millis() as u64;
1879
1880        let action = json!({
1881            "type": "sendToEvmWithData",
1882            "hyperliquidChain": self.inner.chain.as_str(),
1883            "signatureChainId": self.inner.chain.signature_chain_id(),
1884            "token": token,
1885            "amount": amount.to_string(),
1886            "sourceDex": source_dex,
1887            "destinationRecipient": destination,
1888            "addressEncoding": "hex",
1889            "destinationChainId": destination_chain_id,
1890            "gasLimit": gas_limit,
1891            "data": data,
1892            "nonce": nonce,
1893        });
1894
1895        self.inner.build_sign_send(&action).await
1896    }
1897
1898    // ──────────────────────────────────────────────────────────────────────────
1899    // Additional Margin Operations
1900    // ──────────────────────────────────────────────────────────────────────────
1901
1902    /// Top up isolated-only margin to target a specific leverage
1903    pub async fn top_up_isolated_only_margin(
1904        &self,
1905        asset: &str,
1906        leverage: f64,
1907    ) -> Result<Value> {
1908        let asset_idx = self
1909            .inner
1910            .metadata
1911            .resolve_asset(asset)
1912            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1913
1914        let action = json!({
1915            "type": "topUpIsolatedOnlyMargin",
1916            "asset": asset_idx,
1917            "leverage": leverage.to_string(),
1918        });
1919
1920        self.inner.build_sign_send(&action).await
1921    }
1922
1923    // ──────────────────────────────────────────────────────────────────────────
1924    // Validator Operations
1925    // ──────────────────────────────────────────────────────────────────────────
1926
1927    /// Submit a validator vote for the risk-free rate (validator only)
1928    pub async fn validator_l1_stream(&self, risk_free_rate: &str) -> Result<Value> {
1929        let action = json!({
1930            "type": "validatorL1Stream",
1931            "riskFreeRate": risk_free_rate,
1932        });
1933
1934        self.inner.build_sign_send(&action).await
1935    }
1936
1937    // ──────────────────────────────────────────────────────────────────────────
1938    // Cancel Operations
1939    // ──────────────────────────────────────────────────────────────────────────
1940
1941    /// Cancel an order by client order ID (cloid)
1942    pub async fn cancel_by_cloid(&self, cloid: &str, asset: &str) -> Result<Value> {
1943        let asset_idx = self
1944            .inner
1945            .metadata
1946            .resolve_asset(asset)
1947            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
1948
1949        let action = json!({
1950            "type": "cancelByCloid",
1951            "cancels": [{"asset": asset_idx, "cloid": cloid}],
1952        });
1953
1954        self.inner.build_sign_send(&action).await
1955    }
1956
1957    /// Schedule cancellation of all orders after a delay (dead-man's switch)
1958    pub async fn schedule_cancel(&self, time_ms: Option<u64>) -> Result<Value> {
1959        let mut action = json!({"type": "scheduleCancel"});
1960        if let Some(t) = time_ms {
1961            action["time"] = json!(t);
1962        }
1963        self.inner.build_sign_send(&action).await
1964    }
1965
1966    // ──────────────────────────────────────────────────────────────────────────
1967    // EVM Stream Access
1968    // ──────────────────────────────────────────────────────────────────────────
1969    // Convenience Queries
1970    // ──────────────────────────────────────────────────────────────────────────
1971
1972    /// Get mid price for an asset
1973    pub async fn get_mid(&self, asset: &str) -> Result<f64> {
1974        self.inner.get_mid_price(asset).await
1975    }
1976
1977    /// Force refresh of market metadata cache
1978    pub async fn refresh_markets(&self) -> Result<()> {
1979        self.inner.refresh_metadata().await
1980    }
1981}
1982
1983// ══════════════════════════════════════════════════════════════════════════════
1984// Market Order Builder
1985// ══════════════════════════════════════════════════════════════════════════════
1986
1987/// Builder for market orders with size or notional
1988pub struct MarketOrderBuilder {
1989    inner: Arc<HyperliquidSDKInner>,
1990    asset: String,
1991    side: Side,
1992    size: Option<f64>,
1993    notional: Option<f64>,
1994}
1995
1996impl MarketOrderBuilder {
1997    fn new(inner: Arc<HyperliquidSDKInner>, asset: String, side: Side) -> Self {
1998        Self {
1999            inner,
2000            asset,
2001            side,
2002            size: None,
2003            notional: None,
2004        }
2005    }
2006
2007    /// Set order size (in base asset units)
2008    pub fn size(mut self, size: f64) -> Self {
2009        self.size = Some(size);
2010        self
2011    }
2012
2013    /// Set notional value (in USD)
2014    pub fn notional(mut self, notional: f64) -> Self {
2015        self.notional = Some(notional);
2016        self
2017    }
2018
2019    /// Execute the market order
2020    pub async fn execute(self) -> Result<PlacedOrder> {
2021        // Get size decimals for rounding
2022        let sz_decimals = self.inner.metadata.get_asset(&self.asset)
2023            .map(|a| a.sz_decimals)
2024            .unwrap_or(5) as i32;
2025
2026        let size = if let Some(s) = self.size {
2027            s
2028        } else if let Some(notional) = self.notional {
2029            let mid = self.inner.get_mid_price(&self.asset).await?;
2030            notional / mid
2031        } else {
2032            return Err(Error::ValidationError(
2033                "Market order must have size or notional".to_string(),
2034            ));
2035        };
2036
2037        // Round size to allowed decimals
2038        let size_rounded = (size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals);
2039
2040        // Get market price with slippage, rounded to valid tick
2041        let mid = self.inner.get_mid_price(&self.asset).await?;
2042        let price = if self.side.is_buy() {
2043            (mid * (1.0 + self.inner.slippage)).round()
2044        } else {
2045            (mid * (1.0 - self.inner.slippage)).round()
2046        };
2047
2048        let asset_index = self
2049            .inner
2050            .resolve_asset(&self.asset)
2051            .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", self.asset)))?;
2052
2053        // Generate random cloid (Hyperliquid requires nonzero cloid)
2054        let cloid = {
2055            let now = std::time::SystemTime::now()
2056                .duration_since(std::time::UNIX_EPOCH)
2057                .unwrap_or_default();
2058            let nanos = now.as_nanos() as u64;
2059            let hi = nanos.wrapping_mul(0x517cc1b727220a95);
2060            format!("0x{:016x}{:016x}", nanos, hi)
2061        };
2062
2063        let action = json!({
2064            "type": "order",
2065            "orders": [{
2066                "a": asset_index,
2067                "b": self.side.is_buy(),
2068                "p": format!("{}", price),
2069                "s": format!("{}", size_rounded),
2070                "r": false,
2071                "t": {"limit": {"tif": "Ioc"}},
2072                "c": cloid,
2073            }],
2074            "grouping": "na",
2075        });
2076
2077        let response = self.inner.build_sign_send(&action).await?;
2078
2079        Ok(PlacedOrder::from_response(
2080            response,
2081            self.asset,
2082            self.side,
2083            Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
2084            Some(Decimal::from_f64_retain(price).unwrap_or_default()),
2085            Some(self.inner),
2086        ))
2087    }
2088}
2089
2090// Implement await for MarketOrderBuilder
2091impl std::future::IntoFuture for MarketOrderBuilder {
2092    type Output = Result<PlacedOrder>;
2093    type IntoFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send>>;
2094
2095    fn into_future(self) -> Self::IntoFuture {
2096        Box::pin(self.execute())
2097    }
2098}