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}