1use std::str::FromStr;
17
18use alloy::{
19 signers::{SignerSync, local::PrivateKeySigner},
20 sol_types::{Eip712Domain, SolStruct, eip712_domain},
21};
22use alloy_primitives::{Address, B256, keccak256};
23use nautilus_core::hex;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27use super::{nonce::TimeNonce, types::HyperliquidActionType};
28use crate::{
29 common::credential::EvmPrivateKey,
30 http::{
31 error::{Error, Result},
32 models::HyperliquidSignature,
33 },
34};
35
36alloy::sol! {
38 #[derive(Debug, Serialize, Deserialize)]
39 struct Agent {
40 string source;
41 bytes32 connectionId;
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct SignRequest {
48 pub action: Value, pub action_bytes: Option<Vec<u8>>, pub time_nonce: TimeNonce,
51 pub action_type: HyperliquidActionType,
52 pub is_testnet: bool,
53 pub vault_address: Option<String>,
54}
55
56#[derive(Debug, Clone)]
58pub struct SignatureBundle {
59 pub signature: HyperliquidSignature,
60}
61
62#[derive(Debug, Clone)]
64pub struct HyperliquidEip712Signer {
65 signer: PrivateKeySigner,
66 address: String,
67 domain: Eip712Domain,
68}
69
70impl HyperliquidEip712Signer {
71 pub fn new(private_key: &EvmPrivateKey) -> Result<Self> {
77 let key_hex = private_key.as_hex();
78 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
79
80 let signer = PrivateKeySigner::from_str(key_hex)
81 .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
82
83 let address = format!("{:#x}", signer.address());
84
85 let domain = eip712_domain! {
86 name: "Exchange",
87 version: "1",
88 chain_id: 1337,
89 verifying_contract: Address::ZERO,
90 };
91
92 Ok(Self {
93 signer,
94 address,
95 domain,
96 })
97 }
98
99 pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
100 let signature = match request.action_type {
101 HyperliquidActionType::L1 => self.sign_l1_action(request)?,
102 HyperliquidActionType::UserSigned => {
103 return Err(Error::transport(
104 "UserSigned signing is not implemented; all exchange actions use L1",
105 ));
106 }
107 };
108
109 Ok(SignatureBundle { signature })
110 }
111
112 pub fn sign_l1_action(&self, request: &SignRequest) -> Result<HyperliquidSignature> {
113 let connection_id = self.compute_connection_id(request)?;
122
123 let source = if request.is_testnet { "b" } else { "a" };
125
126 let agent = Agent {
127 source: source.to_string(),
128 connectionId: connection_id,
129 };
130
131 let signing_hash = agent.eip712_signing_hash(&self.domain);
133
134 self.sign_hash(&signing_hash.0)
135 }
136
137 fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
138 let mut bytes = if let Some(action_bytes) = &request.action_bytes {
139 action_bytes.clone()
140 } else {
141 log::warn!(
142 "Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
143 );
144 rmp_serde::to_vec_named(&request.action)
145 .map_err(|e| Error::transport(format!("Failed to serialize action: {e}")))?
146 };
147
148 let timestamp = request.time_nonce.as_millis() as u64;
150 bytes.extend_from_slice(×tamp.to_be_bytes());
151
152 if let Some(vault_addr) = &request.vault_address {
153 bytes.push(1); let vault_hex = vault_addr.trim_start_matches("0x");
155 let vault_bytes = hex::decode(vault_hex)
156 .map_err(|e| Error::transport(format!("Invalid vault address: {e}")))?;
157 bytes.extend_from_slice(&vault_bytes);
158 } else {
159 bytes.push(0); }
161
162 Ok(keccak256(&bytes))
163 }
164
165 fn sign_hash(&self, hash: &[u8; 32]) -> Result<HyperliquidSignature> {
166 let hash_b256 = B256::from(*hash);
167
168 let signature = self
169 .signer
170 .sign_hash_sync(&hash_b256)
171 .map_err(|e| Error::transport(format!("Failed to sign hash: {e}")))?;
172
173 let r = signature.r();
174 let s = signature.s();
175 let v = signature.v();
176 let v_byte = if v { 28u8 } else { 27u8 };
177
178 Ok(HyperliquidSignature::new(
179 format!("0x{r:064x}"),
180 format!("0x{s:064x}"),
181 v_byte as u64,
182 ))
183 }
184
185 pub fn address(&self) -> Result<String> {
187 Ok(self.address.clone())
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use alloy::sol_types::SolStruct;
194 use nautilus_model::{identifiers::ClientOrderId, types::Price};
195 use rstest::rstest;
196 use rust_decimal_macros::dec;
197 use serde_json::json;
198
199 use super::*;
200 use crate::http::models::{
201 Cloid, HyperliquidExecAction, HyperliquidExecGrouping, HyperliquidExecLimitParams,
202 HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest, HyperliquidExecTif,
203 };
204
205 #[rstest]
206 fn test_sign_request_l1_action() {
207 let private_key = EvmPrivateKey::new(
208 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
209 )
210 .unwrap();
211 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
212
213 let request = SignRequest {
214 action: json!({
215 "type": "withdraw",
216 "destination": "0xABCDEF123456789",
217 "amount": "100.000"
218 }),
219 action_bytes: None,
220 time_nonce: TimeNonce::from_millis(1640995200000),
221 action_type: HyperliquidActionType::L1,
222 is_testnet: false,
223 vault_address: None,
224 };
225
226 let result = signer.sign(&request).unwrap();
227 let sig_hex = result.signature.to_hex();
228 assert!(sig_hex.starts_with("0x"));
230 assert_eq!(sig_hex.len(), 132); }
232
233 #[rstest]
234 fn test_sign_user_signed_returns_error() {
235 let private_key = EvmPrivateKey::new(
236 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
237 )
238 .unwrap();
239 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
240
241 let request = SignRequest {
242 action: json!({"type": "order"}),
243 action_bytes: None,
244 time_nonce: TimeNonce::from_millis(1640995200000),
245 action_type: HyperliquidActionType::UserSigned,
246 is_testnet: false,
247 vault_address: None,
248 };
249
250 assert!(signer.sign(&request).is_err());
251 }
252
253 #[rstest]
254 fn test_connection_id_matches_python() {
255 let private_key = EvmPrivateKey::new(
261 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
262 )
263 .unwrap();
264 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
265
266 let typed_action = HyperliquidExecAction::Order {
275 orders: vec![HyperliquidExecPlaceOrderRequest {
276 asset: 0,
277 is_buy: true,
278 price: dec!(50000),
279 size: dec!(0.1),
280 reduce_only: false,
281 kind: HyperliquidExecOrderKind::Limit {
282 limit: HyperliquidExecLimitParams {
283 tif: HyperliquidExecTif::Gtc,
284 },
285 },
286 cloid: None,
287 }],
288 grouping: HyperliquidExecGrouping::Na,
289 builder: None,
290 };
291
292 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
294 println!(
295 "Rust typed MsgPack bytes ({}): {}",
296 action_bytes.len(),
297 hex::encode(&action_bytes)
298 );
299
300 let python_msgpack = hex::decode(
302 "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
303 )
304 .unwrap();
305 println!(
306 "Python MsgPack bytes ({}): {}",
307 python_msgpack.len(),
308 hex::encode(&python_msgpack)
309 );
310
311 assert_eq!(
313 hex::encode(&action_bytes),
314 hex::encode(&python_msgpack),
315 "MsgPack bytes should match Python"
316 );
317
318 let action_value = serde_json::to_value(&typed_action).unwrap();
320 let request = SignRequest {
321 action: action_value,
322 action_bytes: Some(action_bytes),
323 time_nonce: TimeNonce::from_millis(1640995200000),
324 action_type: HyperliquidActionType::L1,
325 is_testnet: true, vault_address: None,
327 };
328
329 let connection_id = signer.compute_connection_id(&request).unwrap();
330 println!(
331 "Rust Connection ID: {}",
332 hex::encode(connection_id.as_slice())
333 );
334
335 let expected_connection_id =
337 "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
338 assert_eq!(
339 hex::encode(connection_id.as_slice()),
340 expected_connection_id,
341 "Connection ID should match Python"
342 );
343
344 let source = "b".to_string(); let agent = Agent {
353 source,
354 connectionId: connection_id,
355 };
356
357 let domain = eip712_domain! {
358 name: "Exchange",
359 version: "1",
360 chain_id: 1337,
361 verifying_contract: Address::ZERO,
362 };
363
364 let signing_hash = agent.eip712_signing_hash(&domain);
365 println!(
366 "Rust EIP-712 signing hash: {}",
367 hex::encode(signing_hash.as_slice())
368 );
369
370 let expected_signing_hash =
372 "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
373 assert_eq!(
374 hex::encode(signing_hash.as_slice()),
375 expected_signing_hash,
376 "EIP-712 signing hash should match Python"
377 );
378 }
379
380 #[rstest]
381 fn test_connection_id_with_cloid() {
382 let private_key = EvmPrivateKey::new(
386 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
387 )
388 .unwrap();
389 let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
390
391 let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
393 println!("Cloid hex: {}", cloid.to_hex());
394
395 let typed_action = HyperliquidExecAction::Order {
396 orders: vec![HyperliquidExecPlaceOrderRequest {
397 asset: 0,
398 is_buy: true,
399 price: dec!(50000),
400 size: dec!(0.1),
401 reduce_only: false,
402 kind: HyperliquidExecOrderKind::Limit {
403 limit: HyperliquidExecLimitParams {
404 tif: HyperliquidExecTif::Gtc,
405 },
406 },
407 cloid: Some(cloid),
408 }],
409 grouping: HyperliquidExecGrouping::Na,
410 builder: None,
411 };
412
413 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
415 println!(
416 "Rust MsgPack bytes with cloid ({}): {}",
417 action_bytes.len(),
418 hex::encode(&action_bytes)
419 );
420
421 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
423 println!(
424 "Decoded structure: {}",
425 serde_json::to_string_pretty(&decoded).unwrap()
426 );
427
428 let orders = decoded.get("orders").unwrap().as_array().unwrap();
430 let first_order = &orders[0];
431 let cloid_field = first_order.get("c").unwrap();
432 println!("Cloid in msgpack: {cloid_field}");
433 assert_eq!(
434 cloid_field.as_str().unwrap(),
435 "0x1234567890abcdef1234567890abcdef"
436 );
437
438 let order_json = serde_json::to_string(first_order).unwrap();
440 println!("Order JSON: {order_json}");
441 }
442
443 #[rstest]
444 fn test_cloid_from_client_order_id() {
445 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
448 let cloid = Cloid::from_client_order_id(client_order_id);
449
450 println!("ClientOrderId: {client_order_id}");
451 println!("Cloid hex: {}", cloid.to_hex());
452
453 let hex = cloid.to_hex();
455 assert!(hex.starts_with("0x"), "Should start with 0x");
456 assert_eq!(hex.len(), 34, "Should be 34 chars (0x + 32 hex)");
457
458 for c in hex[2..].chars() {
460 assert!(c.is_ascii_hexdigit(), "Should be hex digit: {c}");
461 }
462
463 let json = serde_json::to_string(&cloid).unwrap();
465 println!("Cloid JSON: {json}");
466 assert!(json.contains(&hex));
467 }
468
469 #[rstest]
470 fn test_production_like_order_with_hashed_cloid() {
471 let private_key = EvmPrivateKey::new(
474 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
475 )
476 .unwrap();
477 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
478
479 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
481 let cloid = Cloid::from_client_order_id(client_order_id);
482
483 println!("=== Production-like Order ===");
484 println!("ClientOrderId: {client_order_id}");
485 println!("Cloid: {}", cloid.to_hex());
486
487 let typed_action = HyperliquidExecAction::Order {
488 orders: vec![HyperliquidExecPlaceOrderRequest {
489 asset: 3, is_buy: true,
491 price: dec!(92572.0),
492 size: dec!(0.001),
493 reduce_only: false,
494 kind: HyperliquidExecOrderKind::Limit {
495 limit: HyperliquidExecLimitParams {
496 tif: HyperliquidExecTif::Gtc,
497 },
498 },
499 cloid: Some(cloid),
500 }],
501 grouping: HyperliquidExecGrouping::Na,
502 builder: None,
503 };
504
505 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
507 println!(
508 "MsgPack bytes ({}): {}",
509 action_bytes.len(),
510 hex::encode(&action_bytes)
511 );
512
513 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
515 println!(
516 "Decoded: {}",
517 serde_json::to_string_pretty(&decoded).unwrap()
518 );
519
520 let action_value = serde_json::to_value(&typed_action).unwrap();
522 let request = SignRequest {
523 action: action_value,
524 action_bytes: Some(action_bytes),
525 time_nonce: TimeNonce::from_millis(1733833200000), action_type: HyperliquidActionType::L1,
527 is_testnet: true, vault_address: None,
529 };
530
531 let connection_id = signer.compute_connection_id(&request).unwrap();
532 println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
533
534 let source = "b".to_string();
536 let agent = Agent {
537 source,
538 connectionId: connection_id,
539 };
540
541 let domain = eip712_domain! {
542 name: "Exchange",
543 version: "1",
544 chain_id: 1337,
545 verifying_contract: Address::ZERO,
546 };
547
548 let signing_hash = agent.eip712_signing_hash(&domain);
549 println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
550
551 let result = signer.sign(&request).unwrap();
553 let sig_hex = result.signature.to_hex();
554 println!("Signature: {sig_hex}");
555 assert!(sig_hex.starts_with("0x"));
556 assert_eq!(sig_hex.len(), 132);
557 }
558
559 #[rstest]
560 fn test_price_decimal_formatting() {
561 let test_cases = [
564 (92572.0_f64, 1_u8, "92572"), (92572.5, 1, "92572.5"), (0.001, 8, "0.001"), (50000.0, 1, "50000"), (0.1, 4, "0.1"), ];
570
571 for (value, precision, expected_normalized) in test_cases {
572 let price = Price::new(value, precision);
573 let price_decimal = price.as_decimal();
574 let normalized = price_decimal.normalize();
575
576 println!(
577 "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
578 );
579
580 assert_eq!(
581 normalized.to_string(),
582 expected_normalized,
583 "Price({value}, {precision}) should normalize to {expected_normalized}"
584 );
585 }
586
587 let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
589 let price_from_dec = dec!(92572.0).normalize();
590 assert_eq!(
591 price_from_type.to_string(),
592 price_from_dec.to_string(),
593 "Price::as_decimal should match dec! macro"
594 );
595 }
596}