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 serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use super::{nonce::TimeNonce, types::HyperliquidActionType};
27use crate::{
28 common::credential::{EvmPrivateKey, VaultAddress},
29 http::{
30 error::{Error, Result},
31 models::HyperliquidSignature,
32 },
33};
34
35alloy::sol! {
37 #[derive(Debug, Serialize, Deserialize)]
38 struct Agent {
39 string source;
40 bytes32 connectionId;
41 }
42}
43
44#[derive(Debug, Clone)]
51pub struct SignRequest {
52 pub action: Option<Value>, pub action_bytes: Option<Vec<u8>>, pub time_nonce: TimeNonce,
55 pub action_type: HyperliquidActionType,
56 pub is_testnet: bool,
57 pub vault_address: Option<VaultAddress>,
58 pub expires_after: Option<u64>,
59}
60
61#[derive(Debug, Clone)]
63pub struct SignatureBundle {
64 pub signature: HyperliquidSignature,
65}
66
67#[derive(Debug, Clone)]
69pub struct HyperliquidEip712Signer {
70 signer: PrivateKeySigner,
71 address: String,
72 domain: Eip712Domain,
73}
74
75impl HyperliquidEip712Signer {
76 pub fn new(private_key: &EvmPrivateKey) -> Result<Self> {
82 let key_hex = private_key.as_hex();
83 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
84
85 let signer = PrivateKeySigner::from_str(key_hex)
86 .map_err(|e| Error::auth(format!("Failed to create signer: {e}")))?;
87
88 let address = format!("{:#x}", signer.address());
89
90 let domain = eip712_domain! {
91 name: "Exchange",
92 version: "1",
93 chain_id: 1337,
94 verifying_contract: Address::ZERO,
95 };
96
97 Ok(Self {
98 signer,
99 address,
100 domain,
101 })
102 }
103
104 pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
105 let signature = match request.action_type {
106 HyperliquidActionType::L1 => self.sign_l1_action(request)?,
107 HyperliquidActionType::UserSigned => {
108 return Err(Error::bad_request(
109 "UserSigned signing is not implemented; all exchange actions use L1",
110 ));
111 }
112 };
113
114 Ok(SignatureBundle { signature })
115 }
116
117 pub fn sign_l1_action(&self, request: &SignRequest) -> Result<HyperliquidSignature> {
118 let connection_id = self.compute_connection_id(request)?;
127
128 let source = if request.is_testnet { "b" } else { "a" };
130
131 let agent = Agent {
132 source: source.to_string(),
133 connectionId: connection_id,
134 };
135
136 let signing_hash = agent.eip712_signing_hash(&self.domain);
138
139 self.sign_hash(&signing_hash.0)
140 }
141
142 fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
143 let mut hasher = Keccak256::new();
144
145 if let Some(action_bytes) = &request.action_bytes {
146 hasher.update(action_bytes);
147 } else {
148 log::warn!(
149 "Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
150 );
151 let action = request.action.as_ref().ok_or_else(|| {
152 Error::bad_request("SignRequest has neither action_bytes nor action")
153 })?;
154 let action_bytes = rmp_serde::to_vec_named(action)
155 .map_err(|e| Error::bad_request(format!("Failed to serialize action: {e}")))?;
156 hasher.update(&action_bytes);
157 }
158
159 let timestamp = request.time_nonce.as_millis() as u64;
160 hasher.update(timestamp.to_be_bytes());
161
162 if let Some(vault_addr) = request.vault_address {
163 hasher.update([1u8]);
164 hasher.update(vault_addr.as_bytes());
165 } else {
166 hasher.update([0u8]);
167 }
168
169 if let Some(expires_after) = request.expires_after {
170 hasher.update([0u8]);
171 hasher.update(expires_after.to_be_bytes());
172 }
173
174 Ok(hasher.finalize())
175 }
176
177 fn sign_hash(&self, hash: &[u8; 32]) -> Result<HyperliquidSignature> {
178 let hash_b256 = B256::from(*hash);
179
180 let signature = self
181 .signer
182 .sign_hash_sync(&hash_b256)
183 .map_err(|e| Error::auth(format!("Failed to sign hash: {e}")))?;
184
185 let r = signature.r();
186 let s = signature.s();
187 let v = signature.v();
188 let v_byte = if v { 28u8 } else { 27u8 };
189
190 Ok(HyperliquidSignature::new(
191 format!("0x{r:064x}"),
192 format!("0x{s:064x}"),
193 v_byte as u64,
194 ))
195 }
196
197 pub fn address(&self) -> Result<String> {
199 Ok(self.address.clone())
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use ahash::AHashSet;
206 use alloy::sol_types::SolStruct;
207 use nautilus_core::hex;
208 use nautilus_model::{identifiers::ClientOrderId, types::Price};
209 use rstest::rstest;
210 use rust_decimal_macros::dec;
211 use serde_json::json;
212
213 use super::*;
214 use crate::http::models::{
215 Cloid, HyperliquidExecAction, HyperliquidExecGrouping, HyperliquidExecLimitParams,
216 HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest, HyperliquidExecTif,
217 };
218
219 #[rstest]
220 fn test_sign_request_l1_action() {
221 let private_key = EvmPrivateKey::new(
222 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
223 )
224 .unwrap();
225 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
226
227 let request = SignRequest {
228 action: Some(json!({
229 "type": "withdraw",
230 "destination": "0xABCDEF123456789",
231 "amount": "100.000"
232 })),
233 action_bytes: None,
234 time_nonce: TimeNonce::from_millis(1640995200000),
235 action_type: HyperliquidActionType::L1,
236 is_testnet: false,
237 vault_address: None,
238 expires_after: None,
239 };
240
241 let result = signer.sign(&request).unwrap();
242 let sig_hex = result.signature.to_hex();
243 assert!(sig_hex.starts_with("0x"));
245 assert_eq!(sig_hex.len(), 132); }
247
248 #[rstest]
250 fn test_sign_l1_rejects_when_action_and_bytes_missing() {
251 let private_key = EvmPrivateKey::new(
252 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
253 )
254 .unwrap();
255 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
256
257 let request = SignRequest {
258 action: None,
259 action_bytes: None,
260 time_nonce: TimeNonce::from_millis(1640995200000),
261 action_type: HyperliquidActionType::L1,
262 is_testnet: false,
263 vault_address: None,
264 expires_after: None,
265 };
266
267 let err = signer.sign(&request).unwrap_err();
268 assert!(
269 matches!(err, Error::BadRequest(_)),
270 "expected BadRequest, was {err:?}",
271 );
272 }
273
274 #[rstest]
275 fn test_sign_user_signed_returns_error() {
276 let private_key = EvmPrivateKey::new(
277 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
278 )
279 .unwrap();
280 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
281
282 let request = SignRequest {
283 action: Some(json!({"type": "order"})),
284 action_bytes: None,
285 time_nonce: TimeNonce::from_millis(1640995200000),
286 action_type: HyperliquidActionType::UserSigned,
287 is_testnet: false,
288 vault_address: None,
289 expires_after: None,
290 };
291
292 let err = signer.sign(&request).unwrap_err();
293 assert!(
294 matches!(err, Error::BadRequest(_)),
295 "expected BadRequest, was {err:?}"
296 );
297 }
298
299 #[rstest]
300 fn test_connection_id_matches_python() {
301 let private_key = EvmPrivateKey::new(
307 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
308 )
309 .unwrap();
310 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
311
312 let typed_action = HyperliquidExecAction::Order {
321 orders: vec![HyperliquidExecPlaceOrderRequest {
322 asset: 0,
323 is_buy: true,
324 price: dec!(50000),
325 size: dec!(0.1),
326 reduce_only: false,
327 kind: HyperliquidExecOrderKind::Limit {
328 limit: HyperliquidExecLimitParams {
329 tif: HyperliquidExecTif::Gtc,
330 },
331 },
332 cloid: None,
333 }],
334 grouping: HyperliquidExecGrouping::Na,
335 builder: None,
336 };
337
338 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
340 println!(
341 "Rust typed MsgPack bytes ({}): {}",
342 action_bytes.len(),
343 hex::encode(&action_bytes)
344 );
345
346 let python_msgpack = hex::decode(
348 "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
349 )
350 .unwrap();
351 println!(
352 "Python MsgPack bytes ({}): {}",
353 python_msgpack.len(),
354 hex::encode(&python_msgpack)
355 );
356
357 assert_eq!(
359 hex::encode(&action_bytes),
360 hex::encode(&python_msgpack),
361 "MsgPack bytes should match Python"
362 );
363
364 let request = SignRequest {
366 action: None,
367 action_bytes: Some(action_bytes),
368 time_nonce: TimeNonce::from_millis(1640995200000),
369 action_type: HyperliquidActionType::L1,
370 is_testnet: true, vault_address: None,
372 expires_after: None,
373 };
374
375 let connection_id = signer.compute_connection_id(&request).unwrap();
376 println!(
377 "Rust Connection ID: {}",
378 hex::encode(connection_id.as_slice())
379 );
380
381 let expected_connection_id =
383 "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
384 assert_eq!(
385 hex::encode(connection_id.as_slice()),
386 expected_connection_id,
387 "Connection ID should match Python"
388 );
389
390 let source = "b".to_string(); let agent = Agent {
399 source,
400 connectionId: connection_id,
401 };
402
403 let domain = eip712_domain! {
404 name: "Exchange",
405 version: "1",
406 chain_id: 1337,
407 verifying_contract: Address::ZERO,
408 };
409
410 let signing_hash = agent.eip712_signing_hash(&domain);
411 println!(
412 "Rust EIP-712 signing hash: {}",
413 hex::encode(signing_hash.as_slice())
414 );
415
416 let expected_signing_hash =
418 "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
419 assert_eq!(
420 hex::encode(signing_hash.as_slice()),
421 expected_signing_hash,
422 "EIP-712 signing hash should match Python"
423 );
424 }
425
426 #[rstest]
427 fn test_connection_id_includes_expires_after_when_present() {
428 let private_key = EvmPrivateKey::new(
429 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
430 )
431 .unwrap();
432 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
433
434 let typed_action = HyperliquidExecAction::Order {
435 orders: vec![HyperliquidExecPlaceOrderRequest {
436 asset: 0,
437 is_buy: true,
438 price: dec!(50000),
439 size: dec!(0.1),
440 reduce_only: false,
441 kind: HyperliquidExecOrderKind::Limit {
442 limit: HyperliquidExecLimitParams {
443 tif: HyperliquidExecTif::Gtc,
444 },
445 },
446 cloid: None,
447 }],
448 grouping: HyperliquidExecGrouping::Na,
449 builder: None,
450 };
451 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
452
453 let without_expiry = SignRequest {
454 action: None,
455 action_bytes: Some(action_bytes),
456 time_nonce: TimeNonce::from_millis(1640995200000),
457 action_type: HyperliquidActionType::L1,
458 is_testnet: true,
459 vault_address: None,
460 expires_after: None,
461 };
462 let with_expiry = SignRequest {
463 expires_after: Some(1640995260000),
464 ..without_expiry.clone()
465 };
466
467 let without_expiry_id = signer.compute_connection_id(&without_expiry).unwrap();
468 let with_expiry_id = signer.compute_connection_id(&with_expiry).unwrap();
469
470 assert_ne!(
471 without_expiry_id, with_expiry_id,
472 "expiresAfter must be part of the L1 action hash",
473 );
474 }
475
476 #[rstest]
477 fn test_connection_id_with_vault_matches_reference() {
478 let private_key = EvmPrivateKey::new(
479 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
480 )
481 .unwrap();
482 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
483
484 let typed_action = HyperliquidExecAction::Order {
485 orders: vec![HyperliquidExecPlaceOrderRequest {
486 asset: 0,
487 is_buy: true,
488 price: dec!(50000),
489 size: dec!(0.1),
490 reduce_only: false,
491 kind: HyperliquidExecOrderKind::Limit {
492 limit: HyperliquidExecLimitParams {
493 tif: HyperliquidExecTif::Gtc,
494 },
495 },
496 cloid: None,
497 }],
498 grouping: HyperliquidExecGrouping::Na,
499 builder: None,
500 };
501 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
502 let request = SignRequest {
503 action: None,
504 action_bytes: Some(action_bytes),
505 time_nonce: TimeNonce::from_millis(1640995200000),
506 action_type: HyperliquidActionType::L1,
507 is_testnet: true,
508 vault_address: Some(
509 VaultAddress::parse("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01").unwrap(),
510 ),
511 expires_after: None,
512 };
513
514 let connection_id = signer.compute_connection_id(&request).unwrap();
515
516 assert_eq!(
517 hex::encode(connection_id.as_slice()),
518 "edc33e36cec99166e20ea113da7e7b028cb94efda22813f814752d719a272757",
519 "connection ID must match the L1 vault signing reference",
520 );
521 }
522
523 #[rstest]
524 fn test_connection_id_with_cloid() {
525 let private_key = EvmPrivateKey::new(
529 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
530 )
531 .unwrap();
532 let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
533
534 let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
536 println!("Cloid hex: {}", cloid.to_hex());
537
538 let typed_action = HyperliquidExecAction::Order {
539 orders: vec![HyperliquidExecPlaceOrderRequest {
540 asset: 0,
541 is_buy: true,
542 price: dec!(50000),
543 size: dec!(0.1),
544 reduce_only: false,
545 kind: HyperliquidExecOrderKind::Limit {
546 limit: HyperliquidExecLimitParams {
547 tif: HyperliquidExecTif::Gtc,
548 },
549 },
550 cloid: Some(cloid),
551 }],
552 grouping: HyperliquidExecGrouping::Na,
553 builder: None,
554 };
555
556 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
558 println!(
559 "Rust MsgPack bytes with cloid ({}): {}",
560 action_bytes.len(),
561 hex::encode(&action_bytes)
562 );
563
564 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
566 println!(
567 "Decoded structure: {}",
568 serde_json::to_string_pretty(&decoded).unwrap()
569 );
570
571 let orders = decoded.get("orders").unwrap().as_array().unwrap();
573 let first_order = &orders[0];
574 let cloid_field = first_order.get("c").unwrap();
575 println!("Cloid in msgpack: {cloid_field}");
576 assert_eq!(
577 cloid_field.as_str().unwrap(),
578 "0x1234567890abcdef1234567890abcdef"
579 );
580
581 let order_json = serde_json::to_string(first_order).unwrap();
583 println!("Order JSON: {order_json}");
584 }
585
586 #[rstest]
587 fn test_cloid_from_client_order_id_is_deterministic() {
588 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
589 let other_client_order_id = ClientOrderId::from("O-20241210-123456-001-001-2");
590 let first = Cloid::from_client_order_id(client_order_id);
591 let second = Cloid::from_client_order_id(client_order_id);
592 let other = Cloid::from_client_order_id(other_client_order_id);
593
594 let first_hex = first.to_hex();
595 let second_hex = second.to_hex();
596 let other_hex = other.to_hex();
597
598 for hex in [&first_hex, &second_hex, &other_hex] {
599 assert!(hex.starts_with("0x"));
600 assert_eq!(hex.len(), 34);
601 assert!(hex[2..].chars().all(|c| c.is_ascii_hexdigit()));
602 assert!(hex[2..].chars().all(|c| !c.is_ascii_uppercase()));
603 }
604
605 assert_eq!(first_hex, "0x7824fcada984a4aa731780e8326c1932");
606 assert_eq!(other_hex, "0x9012504833e63da1435c32e96ef8b873");
607 assert_eq!(first, second);
608 assert_ne!(first, other);
609 }
610
611 #[rstest]
612 fn test_cloid_from_client_order_id_has_varied_leading_bytes() {
613 let cloids: Vec<_> = (0..100)
614 .map(|i| {
615 let client_order_id = ClientOrderId::from(format!("O-SAMPLE-{i:03}").as_str());
616 Cloid::from_client_order_id(client_order_id)
617 })
618 .collect();
619
620 let leading_bytes = cloids
621 .iter()
622 .map(|cloid| cloid.0[0])
623 .collect::<AHashSet<_>>();
624
625 let uuid_like = cloids.iter().filter(|cloid| cloid.is_uuid_v4()).count();
626 assert!(uuid_like < cloids.len());
627 assert!(leading_bytes.len() > 1);
628
629 let unique = cloids.iter().collect::<AHashSet<_>>();
630 assert_eq!(unique.len(), cloids.len());
631 }
632
633 #[rstest]
634 fn test_production_like_order_with_deterministic_cloid() {
635 let private_key = EvmPrivateKey::new(
636 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
637 )
638 .unwrap();
639 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
640
641 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
643 let cloid = Cloid::from_client_order_id(client_order_id);
644
645 println!("=== Production-like Order ===");
646 println!("ClientOrderId: {client_order_id}");
647 println!("Cloid: {}", cloid.to_hex());
648
649 let typed_action = HyperliquidExecAction::Order {
650 orders: vec![HyperliquidExecPlaceOrderRequest {
651 asset: 3, is_buy: true,
653 price: dec!(92572.0),
654 size: dec!(0.001),
655 reduce_only: false,
656 kind: HyperliquidExecOrderKind::Limit {
657 limit: HyperliquidExecLimitParams {
658 tif: HyperliquidExecTif::Gtc,
659 },
660 },
661 cloid: Some(cloid),
662 }],
663 grouping: HyperliquidExecGrouping::Na,
664 builder: None,
665 };
666
667 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
669 println!(
670 "MsgPack bytes ({}): {}",
671 action_bytes.len(),
672 hex::encode(&action_bytes)
673 );
674
675 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
677 println!(
678 "Decoded: {}",
679 serde_json::to_string_pretty(&decoded).unwrap()
680 );
681
682 let request = SignRequest {
684 action: None,
685 action_bytes: Some(action_bytes),
686 time_nonce: TimeNonce::from_millis(1733833200000), action_type: HyperliquidActionType::L1,
688 is_testnet: true, vault_address: None,
690 expires_after: None,
691 };
692
693 let connection_id = signer.compute_connection_id(&request).unwrap();
694 println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
695
696 let source = "b".to_string();
698 let agent = Agent {
699 source,
700 connectionId: connection_id,
701 };
702
703 let domain = eip712_domain! {
704 name: "Exchange",
705 version: "1",
706 chain_id: 1337,
707 verifying_contract: Address::ZERO,
708 };
709
710 let signing_hash = agent.eip712_signing_hash(&domain);
711 println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
712
713 let result = signer.sign(&request).unwrap();
715 let sig_hex = result.signature.to_hex();
716 println!("Signature: {sig_hex}");
717 assert!(sig_hex.starts_with("0x"));
718 assert_eq!(sig_hex.len(), 132);
719 }
720
721 #[rstest]
722 fn test_price_decimal_formatting() {
723 let test_cases = [
726 (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"), ];
732
733 for (value, precision, expected_normalized) in test_cases {
734 let price = Price::new(value, precision);
735 let price_decimal = price.as_decimal();
736 let normalized = price_decimal.normalize();
737
738 println!(
739 "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
740 );
741
742 assert_eq!(
743 normalized.to_string(),
744 expected_normalized,
745 "Price({value}, {precision}) should normalize to {expected_normalized}"
746 );
747 }
748
749 let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
751 let price_from_dec = dec!(92572.0).normalize();
752 assert_eq!(
753 price_from_type.to_string(),
754 price_from_dec.to_string(),
755 "Price::as_decimal should match dec! macro"
756 );
757 }
758}