1use std::str::FromStr;
17
18use alloy_primitives::{Address, B256, keccak256};
19use alloy_signer::SignerSync;
20use alloy_signer_local::PrivateKeySigner;
21use alloy_sol_types::{SolStruct, eip712_domain};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25use super::{nonce::TimeNonce, types::HyperliquidActionType};
26use crate::{
27 common::credential::EvmPrivateKey,
28 http::error::{Error, Result},
29};
30
31alloy_sol_types::sol! {
33 #[derive(Debug, Serialize, Deserialize)]
34 struct Agent {
35 string source;
36 bytes32 connectionId;
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct SignRequest {
43 pub action: Value, pub action_bytes: Option<Vec<u8>>, pub time_nonce: TimeNonce,
46 pub action_type: HyperliquidActionType,
47 pub is_testnet: bool,
48 pub vault_address: Option<String>,
49}
50
51#[derive(Debug, Clone)]
53pub struct SignatureBundle {
54 pub signature: String,
55}
56
57#[derive(Debug, Clone)]
59pub struct HyperliquidEip712Signer {
60 private_key: EvmPrivateKey,
61}
62
63impl HyperliquidEip712Signer {
64 pub fn new(private_key: EvmPrivateKey) -> Self {
65 Self { private_key }
66 }
67
68 pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
69 let signature = match request.action_type {
70 HyperliquidActionType::L1 => self.sign_l1_action(request)?,
71 HyperliquidActionType::UserSigned => {
72 return Err(Error::transport(
73 "UserSigned signing is not implemented; all exchange actions use L1",
74 ));
75 }
76 };
77
78 Ok(SignatureBundle { signature })
79 }
80
81 pub fn sign_l1_action(&self, request: &SignRequest) -> Result<String> {
82 let connection_id = self.compute_connection_id(request)?;
91
92 let source = if request.is_testnet {
94 "b".to_string()
95 } else {
96 "a".to_string()
97 };
98
99 let agent = Agent {
100 source,
101 connectionId: connection_id,
102 };
103
104 let domain = eip712_domain! {
106 name: "Exchange",
107 version: "1",
108 chain_id: 1337,
109 verifying_contract: Address::ZERO,
110 };
111
112 let signing_hash = agent.eip712_signing_hash(&domain);
113
114 self.sign_hash(&signing_hash.0)
115 }
116
117 fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
118 let mut bytes = if let Some(action_bytes) = &request.action_bytes {
119 action_bytes.clone()
120 } else {
121 log::warn!(
122 "Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
123 );
124 rmp_serde::to_vec_named(&request.action)
125 .map_err(|e| Error::transport(format!("Failed to serialize action: {e}")))?
126 };
127
128 let timestamp = request.time_nonce.as_millis() as u64;
130 bytes.extend_from_slice(×tamp.to_be_bytes());
131
132 if let Some(vault_addr) = &request.vault_address {
133 bytes.push(1); let vault_hex = vault_addr.trim_start_matches("0x");
135 let vault_bytes = hex::decode(vault_hex)
136 .map_err(|e| Error::transport(format!("Invalid vault address: {e}")))?;
137 bytes.extend_from_slice(&vault_bytes);
138 } else {
139 bytes.push(0); }
141
142 Ok(keccak256(&bytes))
143 }
144
145 fn sign_hash(&self, hash: &[u8; 32]) -> Result<String> {
146 let key_hex = self.private_key.as_hex();
147 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
148
149 let signer = PrivateKeySigner::from_str(key_hex)
150 .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
151
152 let hash_b256 = B256::from(*hash);
153
154 let signature = signer
155 .sign_hash_sync(&hash_b256)
156 .map_err(|e| Error::transport(format!("Failed to sign hash: {e}")))?;
157
158 let r = signature.r();
161 let s = signature.s();
162 let v = signature.v(); let v_byte = if v { 28u8 } else { 27u8 };
166
167 Ok(format!("0x{r:064x}{s:064x}{v_byte:02x}"))
169 }
170
171 pub fn address(&self) -> Result<String> {
172 let key_hex = self.private_key.as_hex();
174 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
175
176 let signer = PrivateKeySigner::from_str(key_hex)
178 .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
179
180 let address = format!("{:#x}", signer.address());
182 Ok(address)
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use alloy_sol_types::SolStruct;
189 use nautilus_model::{identifiers::ClientOrderId, types::Price};
190 use rstest::rstest;
191 use rust_decimal_macros::dec;
192 use serde_json::json;
193
194 use super::*;
195 use crate::http::models::{
196 Cloid, HyperliquidExecAction, HyperliquidExecBuilderFee, HyperliquidExecGrouping,
197 HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest,
198 HyperliquidExecTif,
199 };
200
201 #[rstest]
202 fn test_sign_request_l1_action() {
203 let private_key = EvmPrivateKey::new(
204 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
205 )
206 .unwrap();
207 let signer = HyperliquidEip712Signer::new(private_key);
208
209 let request = SignRequest {
210 action: json!({
211 "type": "withdraw",
212 "destination": "0xABCDEF123456789",
213 "amount": "100.000"
214 }),
215 action_bytes: None,
216 time_nonce: TimeNonce::from_millis(1640995200000),
217 action_type: HyperliquidActionType::L1,
218 is_testnet: false,
219 vault_address: None,
220 };
221
222 let result = signer.sign(&request).unwrap();
223 assert!(result.signature.starts_with("0x"));
225 assert_eq!(result.signature.len(), 132); }
227
228 #[rstest]
229 fn test_sign_user_signed_returns_error() {
230 let private_key = EvmPrivateKey::new(
231 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
232 )
233 .unwrap();
234 let signer = HyperliquidEip712Signer::new(private_key);
235
236 let request = SignRequest {
237 action: json!({"type": "order"}),
238 action_bytes: None,
239 time_nonce: TimeNonce::from_millis(1640995200000),
240 action_type: HyperliquidActionType::UserSigned,
241 is_testnet: false,
242 vault_address: None,
243 };
244
245 assert!(signer.sign(&request).is_err());
246 }
247
248 #[rstest]
249 fn test_connection_id_matches_python() {
250 let private_key = EvmPrivateKey::new(
256 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
257 )
258 .unwrap();
259 let signer = HyperliquidEip712Signer::new(private_key);
260
261 let typed_action = HyperliquidExecAction::Order {
270 orders: vec![HyperliquidExecPlaceOrderRequest {
271 asset: 0,
272 is_buy: true,
273 price: dec!(50000),
274 size: dec!(0.1),
275 reduce_only: false,
276 kind: HyperliquidExecOrderKind::Limit {
277 limit: HyperliquidExecLimitParams {
278 tif: HyperliquidExecTif::Gtc,
279 },
280 },
281 cloid: None,
282 }],
283 grouping: HyperliquidExecGrouping::Na,
284 builder: None,
285 };
286
287 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
289 println!(
290 "Rust typed MsgPack bytes ({}): {}",
291 action_bytes.len(),
292 hex::encode(&action_bytes)
293 );
294
295 let python_msgpack = hex::decode(
297 "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
298 )
299 .unwrap();
300 println!(
301 "Python MsgPack bytes ({}): {}",
302 python_msgpack.len(),
303 hex::encode(&python_msgpack)
304 );
305
306 assert_eq!(
308 hex::encode(&action_bytes),
309 hex::encode(&python_msgpack),
310 "MsgPack bytes should match Python"
311 );
312
313 let action_value = serde_json::to_value(&typed_action).unwrap();
315 let request = SignRequest {
316 action: action_value,
317 action_bytes: Some(action_bytes),
318 time_nonce: TimeNonce::from_millis(1640995200000),
319 action_type: HyperliquidActionType::L1,
320 is_testnet: true, vault_address: None,
322 };
323
324 let connection_id = signer.compute_connection_id(&request).unwrap();
325 println!(
326 "Rust Connection ID: {}",
327 hex::encode(connection_id.as_slice())
328 );
329
330 let expected_connection_id =
332 "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
333 assert_eq!(
334 hex::encode(connection_id.as_slice()),
335 expected_connection_id,
336 "Connection ID should match Python"
337 );
338
339 let source = "b".to_string(); let agent = Agent {
348 source,
349 connectionId: connection_id,
350 };
351
352 let domain = eip712_domain! {
353 name: "Exchange",
354 version: "1",
355 chain_id: 1337,
356 verifying_contract: Address::ZERO,
357 };
358
359 let signing_hash = agent.eip712_signing_hash(&domain);
360 println!(
361 "Rust EIP-712 signing hash: {}",
362 hex::encode(signing_hash.as_slice())
363 );
364
365 let expected_signing_hash =
367 "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
368 assert_eq!(
369 hex::encode(signing_hash.as_slice()),
370 expected_signing_hash,
371 "EIP-712 signing hash should match Python"
372 );
373 }
374
375 #[rstest]
376 fn test_connection_id_matches_python_with_builder_fee() {
377 let private_key = EvmPrivateKey::new(
384 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
385 )
386 .unwrap();
387 let signer = HyperliquidEip712Signer::new(private_key);
388
389 let typed_action = HyperliquidExecAction::Order {
390 orders: vec![HyperliquidExecPlaceOrderRequest {
391 asset: 0,
392 is_buy: true,
393 price: dec!(50000),
394 size: dec!(0.1),
395 reduce_only: false,
396 kind: HyperliquidExecOrderKind::Limit {
397 limit: HyperliquidExecLimitParams {
398 tif: HyperliquidExecTif::Gtc,
399 },
400 },
401 cloid: None,
402 }],
403 grouping: HyperliquidExecGrouping::Na,
404 builder: Some(HyperliquidExecBuilderFee {
405 address: "0x9b6e2fe4124ed3e7a6b4f85e7860e032222b4316".to_string(),
406 fee_tenths_bp: 1,
407 }),
408 };
409
410 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
412 println!(
413 "Rust typed MsgPack bytes with builder ({}): {}",
414 action_bytes.len(),
415 hex::encode(&action_bytes)
416 );
417
418 let python_msgpack = hex::decode(
420 "84a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61a76275696c64657282a162d92a307839623665326665343132346564336537613662346638356537383630653033323232326234333136a16601",
421 )
422 .unwrap();
423 println!(
424 "Python MsgPack bytes with builder ({}): {}",
425 python_msgpack.len(),
426 hex::encode(&python_msgpack)
427 );
428
429 assert_eq!(
431 hex::encode(&action_bytes),
432 hex::encode(&python_msgpack),
433 "MsgPack bytes with builder should match Python"
434 );
435
436 let action_value = serde_json::to_value(&typed_action).unwrap();
438 let request = SignRequest {
439 action: action_value,
440 action_bytes: Some(action_bytes),
441 time_nonce: TimeNonce::from_millis(1640995200000),
442 action_type: HyperliquidActionType::L1,
443 is_testnet: true,
444 vault_address: None,
445 };
446
447 let connection_id = signer.compute_connection_id(&request).unwrap();
448 println!(
449 "Rust Connection ID with builder: {}",
450 hex::encode(connection_id.as_slice())
451 );
452
453 let expected_connection_id =
454 "235d93388ffa044d5fb14a7fe8103a8a29b73d1e2049cd086e7903671a6cfb49";
455 assert_eq!(
456 hex::encode(connection_id.as_slice()),
457 expected_connection_id,
458 "Connection ID with builder should match Python"
459 );
460
461 let source = "b".to_string();
463 let agent = Agent {
464 source,
465 connectionId: connection_id,
466 };
467
468 let domain = eip712_domain! {
469 name: "Exchange",
470 version: "1",
471 chain_id: 1337,
472 verifying_contract: Address::ZERO,
473 };
474
475 let signing_hash = agent.eip712_signing_hash(&domain);
476 println!(
477 "Rust EIP-712 signing hash with builder: {}",
478 hex::encode(signing_hash.as_slice())
479 );
480
481 let expected_signing_hash =
482 "6f046f4b02e79610b8cf26c73505f8de3ff1d91d6953c5e972fbf198a5311a41";
483 assert_eq!(
484 hex::encode(signing_hash.as_slice()),
485 expected_signing_hash,
486 "EIP-712 signing hash with builder should match Python"
487 );
488 }
489
490 #[rstest]
491 fn test_connection_id_with_cloid() {
492 let private_key = EvmPrivateKey::new(
496 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
497 )
498 .unwrap();
499 let _signer = HyperliquidEip712Signer::new(private_key);
500
501 let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
503 println!("Cloid hex: {}", cloid.to_hex());
504
505 let typed_action = HyperliquidExecAction::Order {
506 orders: vec![HyperliquidExecPlaceOrderRequest {
507 asset: 0,
508 is_buy: true,
509 price: dec!(50000),
510 size: dec!(0.1),
511 reduce_only: false,
512 kind: HyperliquidExecOrderKind::Limit {
513 limit: HyperliquidExecLimitParams {
514 tif: HyperliquidExecTif::Gtc,
515 },
516 },
517 cloid: Some(cloid),
518 }],
519 grouping: HyperliquidExecGrouping::Na,
520 builder: Some(HyperliquidExecBuilderFee {
521 address: "0x9b6e2fe4124ed3e7a6b4f85e7860e032222b4316".to_string(),
522 fee_tenths_bp: 1,
523 }),
524 };
525
526 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
528 println!(
529 "Rust MsgPack bytes with cloid ({}): {}",
530 action_bytes.len(),
531 hex::encode(&action_bytes)
532 );
533
534 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
536 println!(
537 "Decoded structure: {}",
538 serde_json::to_string_pretty(&decoded).unwrap()
539 );
540
541 let orders = decoded.get("orders").unwrap().as_array().unwrap();
543 let first_order = &orders[0];
544 let cloid_field = first_order.get("c").unwrap();
545 println!("Cloid in msgpack: {cloid_field}");
546 assert_eq!(
547 cloid_field.as_str().unwrap(),
548 "0x1234567890abcdef1234567890abcdef"
549 );
550
551 let order_json = serde_json::to_string(first_order).unwrap();
553 println!("Order JSON: {order_json}");
554 }
555
556 #[rstest]
557 fn test_cloid_from_client_order_id() {
558 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
561 let cloid = Cloid::from_client_order_id(client_order_id);
562
563 println!("ClientOrderId: {client_order_id}");
564 println!("Cloid hex: {}", cloid.to_hex());
565
566 let hex = cloid.to_hex();
568 assert!(hex.starts_with("0x"), "Should start with 0x");
569 assert_eq!(hex.len(), 34, "Should be 34 chars (0x + 32 hex)");
570
571 for c in hex[2..].chars() {
573 assert!(c.is_ascii_hexdigit(), "Should be hex digit: {c}");
574 }
575
576 let json = serde_json::to_string(&cloid).unwrap();
578 println!("Cloid JSON: {json}");
579 assert!(json.contains(&hex));
580 }
581
582 #[rstest]
583 fn test_production_like_order_with_hashed_cloid() {
584 let private_key = EvmPrivateKey::new(
587 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
588 )
589 .unwrap();
590 let signer = HyperliquidEip712Signer::new(private_key);
591
592 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
594 let cloid = Cloid::from_client_order_id(client_order_id);
595
596 println!("=== Production-like Order ===");
597 println!("ClientOrderId: {client_order_id}");
598 println!("Cloid: {}", cloid.to_hex());
599
600 let typed_action = HyperliquidExecAction::Order {
601 orders: vec![HyperliquidExecPlaceOrderRequest {
602 asset: 3, is_buy: true,
604 price: dec!(92572.0),
605 size: dec!(0.001),
606 reduce_only: false,
607 kind: HyperliquidExecOrderKind::Limit {
608 limit: HyperliquidExecLimitParams {
609 tif: HyperliquidExecTif::Gtc,
610 },
611 },
612 cloid: Some(cloid),
613 }],
614 grouping: HyperliquidExecGrouping::Na,
615 builder: Some(HyperliquidExecBuilderFee {
616 address: "0x9b6e2fe4124ed3e7a6b4f85e7860e032222b4316".to_string(),
617 fee_tenths_bp: 1,
618 }),
619 };
620
621 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
623 println!(
624 "MsgPack bytes ({}): {}",
625 action_bytes.len(),
626 hex::encode(&action_bytes)
627 );
628
629 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
631 println!(
632 "Decoded: {}",
633 serde_json::to_string_pretty(&decoded).unwrap()
634 );
635
636 let action_value = serde_json::to_value(&typed_action).unwrap();
638 let request = SignRequest {
639 action: action_value,
640 action_bytes: Some(action_bytes),
641 time_nonce: TimeNonce::from_millis(1733833200000), action_type: HyperliquidActionType::L1,
643 is_testnet: true, vault_address: None,
645 };
646
647 let connection_id = signer.compute_connection_id(&request).unwrap();
648 println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
649
650 let source = "b".to_string();
652 let agent = Agent {
653 source,
654 connectionId: connection_id,
655 };
656
657 let domain = eip712_domain! {
658 name: "Exchange",
659 version: "1",
660 chain_id: 1337,
661 verifying_contract: Address::ZERO,
662 };
663
664 let signing_hash = agent.eip712_signing_hash(&domain);
665 println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
666
667 let result = signer.sign(&request).unwrap();
669 println!("Signature: {}", result.signature);
670 assert!(result.signature.starts_with("0x"));
671 assert_eq!(result.signature.len(), 132);
672 }
673
674 #[rstest]
675 fn test_price_decimal_formatting() {
676 let test_cases = [
679 (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"), ];
685
686 for (value, precision, expected_normalized) in test_cases {
687 let price = Price::new(value, precision);
688 let price_decimal = price.as_decimal();
689 let normalized = price_decimal.normalize();
690
691 println!(
692 "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
693 );
694
695 assert_eq!(
696 normalized.to_string(),
697 expected_normalized,
698 "Price({value}, {precision}) should normalize to {expected_normalized}"
699 );
700 }
701
702 let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
704 let price_from_dec = dec!(92572.0).normalize();
705 assert_eq!(
706 price_from_type.to_string(),
707 price_from_dec.to_string(),
708 "Price::as_decimal should match dec! macro"
709 );
710 }
711}