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