ccxt_exchanges/hyperliquid/
signed_request.rs

1//! Signed request builder for Hyperliquid API.
2//!
3//! This module provides a builder pattern for creating authenticated Hyperliquid exchange actions,
4//! encapsulating the common EIP-712 signing workflow used across all authenticated endpoints.
5//!
6//! # Overview
7//!
8//! Unlike HMAC-based exchanges (Binance, OKX, Bitget, Bybit), Hyperliquid uses EIP-712 typed data
9//! signing with Ethereum private keys. The `HyperliquidSignedRequestBuilder` centralizes:
10//! - Private key validation
11//! - Nonce generation (millisecond timestamp)
12//! - EIP-712 signature generation (r, s, v components)
13//! - Request body construction with signature
14//! - HTTP request execution
15//!
16//! # Example
17//!
18//! ```no_run
19//! # use ccxt_exchanges::hyperliquid::HyperLiquid;
20//! # use serde_json::json;
21//! # async fn example() -> ccxt_core::Result<()> {
22//! let hyperliquid = HyperLiquid::builder()
23//!     .private_key("0x...")
24//!     .testnet(true)
25//!     .build()?;
26//!
27//! // Create an order action
28//! let action = json!({
29//!     "type": "order",
30//!     "orders": [{"a": 0, "b": true, "p": "50000", "s": "0.001", "r": false, "t": {"limit": {"tif": "Gtc"}}}],
31//!     "grouping": "na"
32//! });
33//!
34//! let response = hyperliquid.signed_action(action)
35//!     .execute()
36//!     .await?;
37//! # Ok(())
38//! # }
39//! ```
40
41use super::{HyperLiquid, error};
42use ccxt_core::{Error, Result};
43use serde_json::{Map, Value};
44
45/// Builder for creating authenticated Hyperliquid exchange actions.
46///
47/// This builder encapsulates the EIP-712 signing workflow:
48/// 1. Private key validation
49/// 2. Nonce generation (millisecond timestamp)
50/// 3. EIP-712 signature generation via `HyperLiquidAuth.sign_l1_action()`
51/// 4. Request body construction with action, nonce, signature, and optional vault address
52/// 5. HTTP POST request execution to `/exchange` endpoint
53///
54/// # Hyperliquid Signature Format
55///
56/// Hyperliquid uses EIP-712 typed data signing:
57/// - Domain: HyperliquidSignTransaction
58/// - Chain ID: 42161 (mainnet) or 421614 (testnet)
59/// - Signature components: r (32 bytes hex), s (32 bytes hex), v (recovery id)
60///
61/// # Example
62///
63/// ```no_run
64/// # use ccxt_exchanges::hyperliquid::HyperLiquid;
65/// # use serde_json::json;
66/// # async fn example() -> ccxt_core::Result<()> {
67/// let hyperliquid = HyperLiquid::builder()
68///     .private_key("0x...")
69///     .testnet(true)
70///     .build()?;
71///
72/// let action = json!({"type": "cancel", "cancels": [{"a": 0, "o": 12345}]});
73///
74/// let response = hyperliquid.signed_action(action)
75///     .nonce(1234567890000)  // Optional: override auto-generated nonce
76///     .execute()
77///     .await?;
78/// # Ok(())
79/// # }
80/// ```
81pub struct HyperliquidSignedRequestBuilder<'a> {
82    /// Reference to the HyperLiquid exchange instance
83    hyperliquid: &'a HyperLiquid,
84    /// The action to be signed and executed
85    action: Value,
86    /// Optional nonce override (defaults to current timestamp in milliseconds)
87    nonce: Option<u64>,
88}
89
90impl<'a> HyperliquidSignedRequestBuilder<'a> {
91    /// Creates a new signed action builder.
92    ///
93    /// # Arguments
94    ///
95    /// * `hyperliquid` - Reference to the HyperLiquid exchange instance
96    /// * `action` - The action JSON to be signed and executed
97    ///
98    /// # Example
99    ///
100    /// ```no_run
101    /// # use ccxt_exchanges::hyperliquid::HyperLiquid;
102    /// # use ccxt_exchanges::hyperliquid::signed_request::HyperliquidSignedRequestBuilder;
103    /// # use serde_json::json;
104    /// let hyperliquid = HyperLiquid::builder()
105    ///     .private_key("0x...")
106    ///     .testnet(true)
107    ///     .build()
108    ///     .unwrap();
109    ///
110    /// let action = json!({"type": "order", "orders": [], "grouping": "na"});
111    /// let builder = HyperliquidSignedRequestBuilder::new(&hyperliquid, action);
112    /// ```
113    pub fn new(hyperliquid: &'a HyperLiquid, action: Value) -> Self {
114        Self {
115            hyperliquid,
116            action,
117            nonce: None,
118        }
119    }
120
121    /// Sets a custom nonce for the request.
122    ///
123    /// By default, the nonce is automatically generated from the current timestamp
124    /// in milliseconds. Use this method to override the auto-generated nonce.
125    ///
126    /// # Arguments
127    ///
128    /// * `nonce` - The nonce value (typically timestamp in milliseconds)
129    ///
130    /// # Example
131    ///
132    /// ```no_run
133    /// # use ccxt_exchanges::hyperliquid::HyperLiquid;
134    /// # use serde_json::json;
135    /// # async fn example() -> ccxt_core::Result<()> {
136    /// let hyperliquid = HyperLiquid::builder()
137    ///     .private_key("0x...")
138    ///     .testnet(true)
139    ///     .build()?;
140    ///
141    /// let action = json!({"type": "order", "orders": [], "grouping": "na"});
142    ///
143    /// let response = hyperliquid.signed_action(action)
144    ///     .nonce(1234567890000)
145    ///     .execute()
146    ///     .await?;
147    /// # Ok(())
148    /// # }
149    /// ```
150    pub fn nonce(mut self, nonce: u64) -> Self {
151        self.nonce = Some(nonce);
152        self
153    }
154
155    /// Executes the signed action and returns the response.
156    ///
157    /// This method:
158    /// 1. Validates that a private key is configured
159    /// 2. Gets or generates the nonce (millisecond timestamp)
160    /// 3. Signs the action using EIP-712 typed data signing
161    /// 4. Constructs the request body with action, nonce, signature, and optional vault address
162    /// 5. Executes the HTTP POST request to `/exchange` endpoint
163    ///
164    /// # Hyperliquid Signature Details
165    ///
166    /// - Uses EIP-712 typed data signing
167    /// - Domain: HyperliquidSignTransaction, version 1
168    /// - Chain ID: 42161 (mainnet) or 421614 (testnet)
169    /// - Signature format: { r: "0x...", s: "0x...", v: 27|28 }
170    ///
171    /// # Returns
172    ///
173    /// Returns the raw `serde_json::Value` response for further parsing.
174    ///
175    /// # Errors
176    ///
177    /// - Returns authentication error if private key is missing
178    /// - Returns network error if the request fails
179    /// - Returns exchange error if the API returns an error response
180    ///
181    /// # Example
182    ///
183    /// ```no_run
184    /// # use ccxt_exchanges::hyperliquid::HyperLiquid;
185    /// # use serde_json::json;
186    /// # async fn example() -> ccxt_core::Result<()> {
187    /// let hyperliquid = HyperLiquid::builder()
188    ///     .private_key("0x...")
189    ///     .testnet(true)
190    ///     .build()?;
191    ///
192    /// let action = json!({
193    ///     "type": "order",
194    ///     "orders": [{"a": 0, "b": true, "p": "50000", "s": "0.001", "r": false, "t": {"limit": {"tif": "Gtc"}}}],
195    ///     "grouping": "na"
196    /// });
197    ///
198    /// let response = hyperliquid.signed_action(action)
199    ///     .execute()
200    ///     .await?;
201    /// println!("Response: {:?}", response);
202    /// # Ok(())
203    /// # }
204    /// ```
205    pub async fn execute(self) -> Result<Value> {
206        // Step 1: Validate that private key is configured
207        let auth = self
208            .hyperliquid
209            .auth()
210            .ok_or_else(|| Error::authentication("Private key required for exchange actions"))?;
211
212        // Step 2: Get or generate nonce
213        let nonce = self.nonce.unwrap_or_else(|| get_current_nonce());
214
215        // Step 3: Determine if mainnet or testnet
216        let is_mainnet = !self.hyperliquid.options().testnet;
217
218        // Step 4: Sign the action using EIP-712
219        let signature = auth.sign_l1_action(&self.action, nonce, is_mainnet)?;
220
221        // Step 5: Build signature object with r, s, v components
222        let mut signature_map = Map::new();
223        signature_map.insert("r".to_string(), Value::String(format!("0x{}", signature.r)));
224        signature_map.insert("s".to_string(), Value::String(format!("0x{}", signature.s)));
225        signature_map.insert("v".to_string(), Value::Number(signature.v.into()));
226
227        // Step 6: Build request body
228        let mut body_map = Map::new();
229        body_map.insert("action".to_string(), self.action);
230        body_map.insert("nonce".to_string(), Value::Number(nonce.into()));
231        body_map.insert("signature".to_string(), Value::Object(signature_map));
232
233        // Add vault address if configured
234        if let Some(vault_address) = &self.hyperliquid.options().vault_address {
235            body_map.insert(
236                "vaultAddress".to_string(),
237                Value::String(format!("0x{}", hex::encode(vault_address))),
238            );
239        }
240
241        let body = Value::Object(body_map);
242
243        // Step 7: Execute HTTP POST request
244        let urls = self.hyperliquid.urls();
245        let url = format!("{}/exchange", urls.rest);
246
247        tracing::debug!("HyperLiquid signed action request: {:?}", body);
248
249        let response = self
250            .hyperliquid
251            .base()
252            .http_client
253            .post(&url, None, Some(body))
254            .await?;
255
256        // Step 8: Check for error response
257        if error::is_error_response(&response) {
258            return Err(error::parse_error(&response));
259        }
260
261        Ok(response)
262    }
263}
264
265/// Gets the current timestamp in milliseconds as a nonce.
266fn get_current_nonce() -> u64 {
267    chrono::Utc::now().timestamp_millis() as u64
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use serde_json::json;
274
275    // Test private key (DO NOT USE IN PRODUCTION)
276    const TEST_PRIVATE_KEY: &str =
277        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
278
279    #[test]
280    fn test_builder_construction() {
281        let hyperliquid = HyperLiquid::builder().testnet(true).build().unwrap();
282
283        let action = json!({"type": "order", "orders": [], "grouping": "na"});
284        let builder = HyperliquidSignedRequestBuilder::new(&hyperliquid, action.clone());
285
286        assert_eq!(builder.action, action);
287        assert!(builder.nonce.is_none());
288    }
289
290    #[test]
291    fn test_builder_with_nonce() {
292        let hyperliquid = HyperLiquid::builder().testnet(true).build().unwrap();
293
294        let action = json!({"type": "order", "orders": [], "grouping": "na"});
295        let builder =
296            HyperliquidSignedRequestBuilder::new(&hyperliquid, action).nonce(1234567890000);
297
298        assert_eq!(builder.nonce, Some(1234567890000));
299    }
300
301    #[test]
302    fn test_builder_method_chaining() {
303        let hyperliquid = HyperLiquid::builder().testnet(true).build().unwrap();
304
305        let action = json!({"type": "cancel", "cancels": []});
306        let builder =
307            HyperliquidSignedRequestBuilder::new(&hyperliquid, action.clone()).nonce(9999999999999);
308
309        assert_eq!(builder.action, action);
310        assert_eq!(builder.nonce, Some(9999999999999));
311    }
312
313    #[test]
314    fn test_get_current_nonce() {
315        let nonce1 = get_current_nonce();
316        std::thread::sleep(std::time::Duration::from_millis(10));
317        let nonce2 = get_current_nonce();
318
319        // Nonce should be increasing
320        assert!(nonce2 > nonce1);
321
322        // Nonce should be a reasonable timestamp (after year 2020)
323        assert!(nonce1 > 1577836800000); // 2020-01-01 00:00:00 UTC
324    }
325
326    #[test]
327    fn test_builder_with_authenticated_exchange() {
328        let hyperliquid = HyperLiquid::builder()
329            .private_key(TEST_PRIVATE_KEY)
330            .testnet(true)
331            .build()
332            .unwrap();
333
334        let action = json!({"type": "order", "orders": [], "grouping": "na"});
335        let builder = HyperliquidSignedRequestBuilder::new(&hyperliquid, action);
336
337        // Should have auth available
338        assert!(builder.hyperliquid.auth().is_some());
339    }
340
341    #[test]
342    fn test_builder_without_authentication() {
343        let hyperliquid = HyperLiquid::builder().testnet(true).build().unwrap();
344
345        let action = json!({"type": "order", "orders": [], "grouping": "na"});
346        let builder = HyperliquidSignedRequestBuilder::new(&hyperliquid, action);
347
348        // Should not have auth available
349        assert!(builder.hyperliquid.auth().is_none());
350    }
351
352    #[tokio::test]
353    async fn test_execute_without_credentials_returns_error() {
354        let hyperliquid = HyperLiquid::builder().testnet(true).build().unwrap();
355
356        let action = json!({"type": "order", "orders": [], "grouping": "na"});
357        let result = hyperliquid.signed_action(action).execute().await;
358
359        assert!(result.is_err());
360        let err = result.unwrap_err();
361        assert!(err.to_string().contains("Private key required"));
362    }
363
364    #[test]
365    fn test_different_action_types() {
366        let hyperliquid = HyperLiquid::builder().testnet(true).build().unwrap();
367
368        // Order action
369        let order_action = json!({
370            "type": "order",
371            "orders": [{"a": 0, "b": true, "p": "50000", "s": "0.001", "r": false, "t": {"limit": {"tif": "Gtc"}}}],
372            "grouping": "na"
373        });
374        let builder = HyperliquidSignedRequestBuilder::new(&hyperliquid, order_action.clone());
375        assert_eq!(builder.action["type"], "order");
376
377        // Cancel action
378        let cancel_action = json!({
379            "type": "cancel",
380            "cancels": [{"a": 0, "o": 12345}]
381        });
382        let builder = HyperliquidSignedRequestBuilder::new(&hyperliquid, cancel_action.clone());
383        assert_eq!(builder.action["type"], "cancel");
384
385        // Update leverage action
386        let leverage_action = json!({
387            "type": "updateLeverage",
388            "asset": 0,
389            "isCross": true,
390            "leverage": 10
391        });
392        let builder = HyperliquidSignedRequestBuilder::new(&hyperliquid, leverage_action.clone());
393        assert_eq!(builder.action["type"], "updateLeverage");
394    }
395}