1use std::{fmt::Debug, 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::string::secret::REDACTED;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27use super::{nonce::TimeNonce, types::HyperliquidActionType};
28use crate::{
29 common::credential::{EvmPrivateKey, VaultAddress},
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)]
52pub struct SignRequest {
53 pub action: Option<Value>, pub action_bytes: Option<Vec<u8>>, pub time_nonce: TimeNonce,
56 pub action_type: HyperliquidActionType,
57 pub is_testnet: bool,
58 pub vault_address: Option<VaultAddress>,
59 pub expires_after: Option<u64>,
60}
61
62#[derive(Debug, Clone)]
64pub struct SignatureBundle {
65 pub signature: HyperliquidSignature,
66}
67
68#[derive(Clone)]
70pub struct HyperliquidEip712Signer {
71 signer: PrivateKeySigner,
72 address: String,
73 domain: Eip712Domain,
74}
75
76impl Debug for HyperliquidEip712Signer {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.debug_struct(stringify!(HyperliquidEip712Signer))
79 .field("signer", &REDACTED)
80 .field("address", &self.address)
81 .field("domain", &self.domain)
82 .finish()
83 }
84}
85
86impl HyperliquidEip712Signer {
87 pub fn new(private_key: &EvmPrivateKey) -> Result<Self> {
93 let key_hex = private_key.as_hex();
94 let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
95
96 let signer = PrivateKeySigner::from_str(key_hex)
97 .map_err(|e| Error::auth(format!("Failed to create signer: {e}")))?;
98
99 let address = format!("{:#x}", signer.address());
100
101 let domain = eip712_domain! {
102 name: "Exchange",
103 version: "1",
104 chain_id: 1337,
105 verifying_contract: Address::ZERO,
106 };
107
108 Ok(Self {
109 signer,
110 address,
111 domain,
112 })
113 }
114
115 pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
116 let signature = match request.action_type {
117 HyperliquidActionType::L1 => self.sign_l1_action(request)?,
118 HyperliquidActionType::UserSigned => {
119 return Err(Error::bad_request(
120 "UserSigned signing is not implemented; all exchange actions use L1",
121 ));
122 }
123 };
124
125 Ok(SignatureBundle { signature })
126 }
127
128 pub fn sign_l1_action(&self, request: &SignRequest) -> Result<HyperliquidSignature> {
129 let connection_id = self.compute_connection_id(request)?;
138
139 let source = if request.is_testnet { "b" } else { "a" };
141
142 let agent = Agent {
143 source: source.to_string(),
144 connectionId: connection_id,
145 };
146
147 let signing_hash = agent.eip712_signing_hash(&self.domain);
149
150 self.sign_hash(&signing_hash.0)
151 }
152
153 fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
154 let mut hasher = Keccak256::new();
155
156 if let Some(action_bytes) = &request.action_bytes {
157 hasher.update(action_bytes);
158 } else {
159 log::warn!(
160 "Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
161 );
162 let action = request.action.as_ref().ok_or_else(|| {
163 Error::bad_request("SignRequest has neither action_bytes nor action")
164 })?;
165 let action_bytes = rmp_serde::to_vec_named(action)
166 .map_err(|e| Error::bad_request(format!("Failed to serialize action: {e}")))?;
167 hasher.update(&action_bytes);
168 }
169
170 let timestamp = request.time_nonce.as_millis() as u64;
171 hasher.update(timestamp.to_be_bytes());
172
173 if let Some(vault_addr) = request.vault_address {
174 hasher.update([1u8]);
175 hasher.update(vault_addr.as_bytes());
176 } else {
177 hasher.update([0u8]);
178 }
179
180 if let Some(expires_after) = request.expires_after {
181 hasher.update([0u8]);
182 hasher.update(expires_after.to_be_bytes());
183 }
184
185 Ok(hasher.finalize())
186 }
187
188 fn sign_hash(&self, hash: &[u8; 32]) -> Result<HyperliquidSignature> {
189 let hash_b256 = B256::from(*hash);
190
191 let signature = self
192 .signer
193 .sign_hash_sync(&hash_b256)
194 .map_err(|e| Error::auth(format!("Failed to sign hash: {e}")))?;
195
196 let r = signature.r();
197 let s = signature.s();
198 let v = signature.v();
199 let v_byte = if v { 28u8 } else { 27u8 };
200
201 Ok(HyperliquidSignature::new(
202 format!("0x{r:064x}"),
203 format!("0x{s:064x}"),
204 v_byte as u64,
205 ))
206 }
207
208 pub fn address(&self) -> Result<String> {
210 Ok(self.address.clone())
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use ahash::AHashSet;
217 use alloy::sol_types::SolStruct;
218 use nautilus_core::hex;
219 use nautilus_model::{identifiers::ClientOrderId, types::Price};
220 use rstest::rstest;
221 use rust_decimal_macros::dec;
222 use serde_json::json;
223
224 use super::*;
225 use crate::http::models::{
226 Cloid, HyperliquidExchangeAction, HyperliquidExchangeGrouping,
227 HyperliquidExchangeLimitParams, HyperliquidExchangeOrderKind,
228 HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeTif,
229 };
230
231 #[rstest]
232 fn test_sign_request_l1_action() {
233 let private_key = EvmPrivateKey::new(
234 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
235 )
236 .unwrap();
237 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
238 let debug = format!("{signer:?}");
239
240 let request = SignRequest {
241 action: Some(json!({
242 "type": "withdraw",
243 "destination": "0xABCDEF123456789",
244 "amount": "100.000"
245 })),
246 action_bytes: None,
247 time_nonce: TimeNonce::from_millis(1640995200000),
248 action_type: HyperliquidActionType::L1,
249 is_testnet: false,
250 vault_address: None,
251 expires_after: None,
252 };
253
254 let result = signer.sign(&request).unwrap();
255 let sig_hex = result.signature.to_hex();
256 assert!(sig_hex.expose_secret().starts_with("0x"));
258 assert_eq!(sig_hex.expose_secret().len(), 132); assert!(debug.contains(REDACTED));
260 assert!(!debug.contains(private_key.as_hex()));
261 }
262
263 #[rstest]
265 fn test_sign_l1_rejects_when_action_and_bytes_missing() {
266 let private_key = EvmPrivateKey::new(
267 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
268 )
269 .unwrap();
270 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
271
272 let request = SignRequest {
273 action: None,
274 action_bytes: None,
275 time_nonce: TimeNonce::from_millis(1640995200000),
276 action_type: HyperliquidActionType::L1,
277 is_testnet: false,
278 vault_address: None,
279 expires_after: None,
280 };
281
282 let err = signer.sign(&request).unwrap_err();
283 assert!(
284 matches!(err, Error::BadRequest(_)),
285 "expected BadRequest, was {err:?}",
286 );
287 }
288
289 #[rstest]
290 fn official_l1_dummy_action_signature_matches_python_sdk_for_both_environments() {
291 #[derive(Serialize)]
294 struct DummyAction<'a> {
295 #[serde(rename = "type")]
296 action_type: &'a str,
297 num: u64,
298 }
299
300 let python_quantity_hex = |value: &str| {
301 let digits = value.trim_start_matches("0x").trim_start_matches('0');
302 format!("0x{}", if digits.is_empty() { "0" } else { digits })
303 };
304
305 let private_key = EvmPrivateKey::new(
306 "0x0123456789012345678901234567890123456789012345678901234567890123",
307 )
308 .unwrap();
309 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
310 let action_bytes = rmp_serde::to_vec_named(&DummyAction {
311 action_type: "dummy",
312 num: 100_000_000_000,
313 })
314 .unwrap();
315 let request = |is_testnet| SignRequest {
316 action: None,
317 action_bytes: Some(action_bytes.clone()),
318 time_nonce: TimeNonce::from_millis(0),
319 action_type: HyperliquidActionType::L1,
320 is_testnet,
321 vault_address: None,
322 expires_after: None,
323 };
324
325 let mainnet = signer.sign_l1_action(&request(false)).unwrap();
326 assert_eq!(
327 python_quantity_hex(mainnet.r.expose_secret()),
328 "0x53749d5b30552aeb2fca34b530185976545bb22d0b3ce6f62e31be961a59298"
329 );
330 assert_eq!(
331 mainnet.s.expose_secret(),
332 "0x755c40ba9bf05223521753995abb2f73ab3229be8ec921f350cb447e384d8ed8"
333 );
334 assert_eq!(mainnet.v, 27);
335
336 let testnet = signer.sign_l1_action(&request(true)).unwrap();
337 assert_eq!(
338 testnet.r.expose_secret(),
339 "0x542af61ef1f429707e3c76c5293c80d01f74ef853e34b76efffcb57e574f9510"
340 );
341 assert_eq!(
342 testnet.s.expose_secret(),
343 "0x17b8b32f086e8cdede991f1e2c529f5dd5297cbe8128500e00cbaf766204a613"
344 );
345 assert_eq!(testnet.v, 28);
346 }
347
348 #[rstest]
349 #[case("limit")]
350 #[case("trigger")]
351 #[case("batch_modify")]
352 fn test_order_decimal_signing_matches_python_sdk(
353 #[case] case_name: &str,
354 #[values(false, true)] is_testnet: bool,
355 ) {
356 let fixture: serde_json::Value =
357 serde_json::from_str(include_str!("../../test_data/order_decimal_signing.json"))
358 .unwrap();
359 let case = fixture["cases"]
360 .as_array()
361 .unwrap()
362 .iter()
363 .find(|case| case["name"] == case_name)
364 .unwrap();
365 let action: HyperliquidExchangeAction =
366 serde_json::from_value(case["input"].clone()).unwrap();
367 let action_bytes = rmp_serde::to_vec_named(&action).unwrap();
368 let key = EvmPrivateKey::new(
369 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
370 )
371 .unwrap();
372 let signer = HyperliquidEip712Signer::new(&key).unwrap();
373
374 let request = SignRequest {
375 action: None,
376 action_bytes: Some(action_bytes.clone()),
377 time_nonce: TimeNonce::from_millis(i128::from(fixture["nonce"].as_u64().unwrap())),
378 action_type: HyperliquidActionType::L1,
379 is_testnet,
380 vault_address: None,
381 expires_after: None,
382 };
383
384 let connection_id = signer.compute_connection_id(&request).unwrap();
385 let signature = signer.sign_l1_action(&request).unwrap();
386
387 let expected_signature = &case[if is_testnet {
388 "testnet_signature"
389 } else {
390 "mainnet_signature"
391 }];
392
393 let expected_r = format!(
394 "0x{:0>64}",
395 expected_signature["r"]
396 .as_str()
397 .unwrap()
398 .trim_start_matches("0x")
399 );
400 let expected_s = format!(
401 "0x{:0>64}",
402 expected_signature["s"]
403 .as_str()
404 .unwrap()
405 .trim_start_matches("0x")
406 );
407
408 assert_eq!(serde_json::to_value(&action).unwrap(), case["action"]);
409 assert_eq!(hex::encode(action_bytes), case["msgpack"].as_str().unwrap());
410 assert_eq!(
411 hex::encode(connection_id),
412 case["connection_id"].as_str().unwrap()
413 );
414 assert_eq!(signature.r.expose_secret(), expected_r);
415 assert_eq!(signature.s.expose_secret(), expected_s);
416 assert_eq!(signature.v, expected_signature["v"].as_u64().unwrap());
417 }
418
419 #[rstest]
420 fn test_sign_user_signed_returns_error() {
421 let private_key = EvmPrivateKey::new(
422 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
423 )
424 .unwrap();
425 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
426
427 let request = SignRequest {
428 action: Some(json!({"type": "order"})),
429 action_bytes: None,
430 time_nonce: TimeNonce::from_millis(1640995200000),
431 action_type: HyperliquidActionType::UserSigned,
432 is_testnet: false,
433 vault_address: None,
434 expires_after: None,
435 };
436
437 let err = signer.sign(&request).unwrap_err();
438 assert!(
439 matches!(err, Error::BadRequest(_)),
440 "expected BadRequest, was {err:?}"
441 );
442 }
443
444 #[rstest]
445 fn test_connection_id_matches_python() {
446 let private_key = EvmPrivateKey::new(
452 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
453 )
454 .unwrap();
455 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
456
457 let typed_action = HyperliquidExchangeAction::Order {
466 orders: vec![HyperliquidExchangePlaceOrderRequest {
467 asset: 0,
468 is_buy: true,
469 price: dec!(50000),
470 size: dec!(0.1),
471 reduce_only: false,
472 kind: HyperliquidExchangeOrderKind::Limit {
473 limit: HyperliquidExchangeLimitParams {
474 tif: HyperliquidExchangeTif::Gtc,
475 },
476 },
477 cloid: None,
478 }],
479 grouping: HyperliquidExchangeGrouping::Na,
480 builder: None,
481 };
482
483 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
485 println!(
486 "Rust typed MsgPack bytes ({}): {}",
487 action_bytes.len(),
488 hex::encode(&action_bytes)
489 );
490
491 let python_msgpack = hex::decode(
493 "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
494 )
495 .unwrap();
496 println!(
497 "Python MsgPack bytes ({}): {}",
498 python_msgpack.len(),
499 hex::encode(&python_msgpack)
500 );
501
502 assert_eq!(
504 hex::encode(&action_bytes),
505 hex::encode(&python_msgpack),
506 "MsgPack bytes should match Python"
507 );
508
509 let request = SignRequest {
511 action: None,
512 action_bytes: Some(action_bytes),
513 time_nonce: TimeNonce::from_millis(1640995200000),
514 action_type: HyperliquidActionType::L1,
515 is_testnet: true, vault_address: None,
517 expires_after: None,
518 };
519
520 let connection_id = signer.compute_connection_id(&request).unwrap();
521 println!(
522 "Rust Connection ID: {}",
523 hex::encode(connection_id.as_slice())
524 );
525
526 let expected_connection_id =
528 "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
529 assert_eq!(
530 hex::encode(connection_id.as_slice()),
531 expected_connection_id,
532 "Connection ID should match Python"
533 );
534
535 let source = "b".to_string(); let agent = Agent {
544 source,
545 connectionId: connection_id,
546 };
547
548 let domain = eip712_domain! {
549 name: "Exchange",
550 version: "1",
551 chain_id: 1337,
552 verifying_contract: Address::ZERO,
553 };
554
555 let signing_hash = agent.eip712_signing_hash(&domain);
556 println!(
557 "Rust EIP-712 signing hash: {}",
558 hex::encode(signing_hash.as_slice())
559 );
560
561 let expected_signing_hash =
563 "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
564 assert_eq!(
565 hex::encode(signing_hash.as_slice()),
566 expected_signing_hash,
567 "EIP-712 signing hash should match Python"
568 );
569 }
570
571 #[rstest]
572 fn test_connection_id_includes_expires_after_when_present() {
573 let private_key = EvmPrivateKey::new(
574 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
575 )
576 .unwrap();
577 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
578
579 let typed_action = HyperliquidExchangeAction::Order {
580 orders: vec![HyperliquidExchangePlaceOrderRequest {
581 asset: 0,
582 is_buy: true,
583 price: dec!(50000),
584 size: dec!(0.1),
585 reduce_only: false,
586 kind: HyperliquidExchangeOrderKind::Limit {
587 limit: HyperliquidExchangeLimitParams {
588 tif: HyperliquidExchangeTif::Gtc,
589 },
590 },
591 cloid: None,
592 }],
593 grouping: HyperliquidExchangeGrouping::Na,
594 builder: None,
595 };
596 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
597
598 let without_expiry = SignRequest {
599 action: None,
600 action_bytes: Some(action_bytes),
601 time_nonce: TimeNonce::from_millis(1640995200000),
602 action_type: HyperliquidActionType::L1,
603 is_testnet: true,
604 vault_address: None,
605 expires_after: None,
606 };
607 let with_expiry = SignRequest {
608 expires_after: Some(1640995260000),
609 ..without_expiry.clone()
610 };
611
612 let without_expiry_id = signer.compute_connection_id(&without_expiry).unwrap();
613 let with_expiry_id = signer.compute_connection_id(&with_expiry).unwrap();
614
615 assert_ne!(
616 without_expiry_id, with_expiry_id,
617 "expiresAfter must be part of the L1 action hash",
618 );
619 }
620
621 #[rstest]
622 fn test_connection_id_with_vault_matches_reference() {
623 let private_key = EvmPrivateKey::new(
624 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
625 )
626 .unwrap();
627 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
628
629 let typed_action = HyperliquidExchangeAction::Order {
630 orders: vec![HyperliquidExchangePlaceOrderRequest {
631 asset: 0,
632 is_buy: true,
633 price: dec!(50000),
634 size: dec!(0.1),
635 reduce_only: false,
636 kind: HyperliquidExchangeOrderKind::Limit {
637 limit: HyperliquidExchangeLimitParams {
638 tif: HyperliquidExchangeTif::Gtc,
639 },
640 },
641 cloid: None,
642 }],
643 grouping: HyperliquidExchangeGrouping::Na,
644 builder: None,
645 };
646 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
647 let request = SignRequest {
648 action: None,
649 action_bytes: Some(action_bytes),
650 time_nonce: TimeNonce::from_millis(1640995200000),
651 action_type: HyperliquidActionType::L1,
652 is_testnet: true,
653 vault_address: Some(
654 VaultAddress::parse("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01").unwrap(),
655 ),
656 expires_after: None,
657 };
658
659 let connection_id = signer.compute_connection_id(&request).unwrap();
660
661 assert_eq!(
662 hex::encode(connection_id.as_slice()),
663 "edc33e36cec99166e20ea113da7e7b028cb94efda22813f814752d719a272757",
664 "connection ID must match the L1 vault signing reference",
665 );
666 }
667
668 #[rstest]
669 fn test_connection_id_with_cloid() {
670 let private_key = EvmPrivateKey::new(
674 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
675 )
676 .unwrap();
677 let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
678
679 let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
681 println!("Cloid hex: {}", cloid.to_hex());
682
683 let typed_action = HyperliquidExchangeAction::Order {
684 orders: vec![HyperliquidExchangePlaceOrderRequest {
685 asset: 0,
686 is_buy: true,
687 price: dec!(50000),
688 size: dec!(0.1),
689 reduce_only: false,
690 kind: HyperliquidExchangeOrderKind::Limit {
691 limit: HyperliquidExchangeLimitParams {
692 tif: HyperliquidExchangeTif::Gtc,
693 },
694 },
695 cloid: Some(cloid),
696 }],
697 grouping: HyperliquidExchangeGrouping::Na,
698 builder: None,
699 };
700
701 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
703 println!(
704 "Rust MsgPack bytes with cloid ({}): {}",
705 action_bytes.len(),
706 hex::encode(&action_bytes)
707 );
708
709 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
711 println!(
712 "Decoded structure: {}",
713 serde_json::to_string_pretty(&decoded).unwrap()
714 );
715
716 let orders = decoded.get("orders").unwrap().as_array().unwrap();
718 let first_order = &orders[0];
719 let cloid_field = first_order.get("c").unwrap();
720 println!("Cloid in msgpack: {cloid_field}");
721 assert_eq!(
722 cloid_field.as_str().unwrap(),
723 "0x1234567890abcdef1234567890abcdef"
724 );
725
726 let order_json = serde_json::to_string(first_order).unwrap();
728 println!("Order JSON: {order_json}");
729 }
730
731 #[rstest]
732 fn test_cloid_from_client_order_id_is_deterministic() {
733 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
734 let other_client_order_id = ClientOrderId::from("O-20241210-123456-001-001-2");
735 let first = Cloid::from_client_order_id(client_order_id);
736 let second = Cloid::from_client_order_id(client_order_id);
737 let other = Cloid::from_client_order_id(other_client_order_id);
738
739 let first_hex = first.to_hex();
740 let second_hex = second.to_hex();
741 let other_hex = other.to_hex();
742
743 for hex in [&first_hex, &second_hex, &other_hex] {
744 assert!(hex.starts_with("0x"));
745 assert_eq!(hex.len(), 34);
746 assert!(hex[2..].chars().all(|c| c.is_ascii_hexdigit()));
747 assert!(hex[2..].chars().all(|c| !c.is_ascii_uppercase()));
748 }
749
750 assert_eq!(first_hex, "0x7824fcada984a4aa731780e8326c1932");
751 assert_eq!(other_hex, "0x9012504833e63da1435c32e96ef8b873");
752 assert_eq!(first, second);
753 assert_ne!(first, other);
754 }
755
756 #[rstest]
757 fn test_cloid_from_client_order_id_has_varied_leading_bytes() {
758 let cloids: Vec<_> = (0..100)
759 .map(|i| {
760 let client_order_id = ClientOrderId::from(format!("O-SAMPLE-{i:03}").as_str());
761 Cloid::from_client_order_id(client_order_id)
762 })
763 .collect();
764
765 let leading_bytes = cloids
766 .iter()
767 .map(|cloid| cloid.0[0])
768 .collect::<AHashSet<_>>();
769
770 let uuid_like = cloids.iter().filter(|cloid| cloid.is_uuid_v4()).count();
771 assert!(uuid_like < cloids.len());
772 assert!(leading_bytes.len() > 1);
773
774 let unique = cloids.iter().collect::<AHashSet<_>>();
775 assert_eq!(unique.len(), cloids.len());
776 }
777
778 #[rstest]
779 fn test_production_like_order_with_deterministic_cloid() {
780 let private_key = EvmPrivateKey::new(
781 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
782 )
783 .unwrap();
784 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
785
786 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
788 let cloid = Cloid::from_client_order_id(client_order_id);
789
790 println!("=== Production-like Order ===");
791 println!("ClientOrderId: {client_order_id}");
792 println!("Cloid: {}", cloid.to_hex());
793
794 let typed_action = HyperliquidExchangeAction::Order {
795 orders: vec![HyperliquidExchangePlaceOrderRequest {
796 asset: 3, is_buy: true,
798 price: dec!(92572.0),
799 size: dec!(0.001),
800 reduce_only: false,
801 kind: HyperliquidExchangeOrderKind::Limit {
802 limit: HyperliquidExchangeLimitParams {
803 tif: HyperliquidExchangeTif::Gtc,
804 },
805 },
806 cloid: Some(cloid),
807 }],
808 grouping: HyperliquidExchangeGrouping::Na,
809 builder: None,
810 };
811
812 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
814 println!(
815 "MsgPack bytes ({}): {}",
816 action_bytes.len(),
817 hex::encode(&action_bytes)
818 );
819
820 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
822 println!(
823 "Decoded: {}",
824 serde_json::to_string_pretty(&decoded).unwrap()
825 );
826
827 let request = SignRequest {
829 action: None,
830 action_bytes: Some(action_bytes),
831 time_nonce: TimeNonce::from_millis(1733833200000), action_type: HyperliquidActionType::L1,
833 is_testnet: true, vault_address: None,
835 expires_after: None,
836 };
837
838 let connection_id = signer.compute_connection_id(&request).unwrap();
839 println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
840
841 let source = "b".to_string();
843 let agent = Agent {
844 source,
845 connectionId: connection_id,
846 };
847
848 let domain = eip712_domain! {
849 name: "Exchange",
850 version: "1",
851 chain_id: 1337,
852 verifying_contract: Address::ZERO,
853 };
854
855 let signing_hash = agent.eip712_signing_hash(&domain);
856 println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
857
858 let result = signer.sign(&request).unwrap();
860 let sig_hex = result.signature.to_hex();
861 println!("Signature: {}", sig_hex.expose_secret());
862 assert!(sig_hex.expose_secret().starts_with("0x"));
863 assert_eq!(sig_hex.expose_secret().len(), 132);
864 }
865
866 #[rstest]
867 fn test_price_decimal_formatting() {
868 let test_cases = [
871 (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"), ];
877
878 for (value, precision, expected_normalized) in test_cases {
879 let price = Price::new(value, precision);
880 let price_decimal = price.as_decimal();
881 let normalized = price_decimal.normalize();
882
883 println!(
884 "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
885 );
886
887 assert_eq!(
888 normalized.to_string(),
889 expected_normalized,
890 "Price({value}, {precision}) should normalize to {expected_normalized}"
891 );
892 }
893
894 let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
896 let price_from_dec = dec!(92572.0).normalize();
897 assert_eq!(
898 price_from_type.to_string(),
899 price_from_dec.to_string(),
900 "Price::as_decimal should match dec! macro"
901 );
902 }
903}