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 fn test_sign_user_signed_returns_error() {
350 let private_key = EvmPrivateKey::new(
351 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
352 )
353 .unwrap();
354 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
355
356 let request = SignRequest {
357 action: Some(json!({"type": "order"})),
358 action_bytes: None,
359 time_nonce: TimeNonce::from_millis(1640995200000),
360 action_type: HyperliquidActionType::UserSigned,
361 is_testnet: false,
362 vault_address: None,
363 expires_after: None,
364 };
365
366 let err = signer.sign(&request).unwrap_err();
367 assert!(
368 matches!(err, Error::BadRequest(_)),
369 "expected BadRequest, was {err:?}"
370 );
371 }
372
373 #[rstest]
374 fn test_connection_id_matches_python() {
375 let private_key = EvmPrivateKey::new(
381 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
382 )
383 .unwrap();
384 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
385
386 let typed_action = HyperliquidExchangeAction::Order {
395 orders: vec![HyperliquidExchangePlaceOrderRequest {
396 asset: 0,
397 is_buy: true,
398 price: dec!(50000),
399 size: dec!(0.1),
400 reduce_only: false,
401 kind: HyperliquidExchangeOrderKind::Limit {
402 limit: HyperliquidExchangeLimitParams {
403 tif: HyperliquidExchangeTif::Gtc,
404 },
405 },
406 cloid: None,
407 }],
408 grouping: HyperliquidExchangeGrouping::Na,
409 builder: None,
410 };
411
412 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
414 println!(
415 "Rust typed MsgPack bytes ({}): {}",
416 action_bytes.len(),
417 hex::encode(&action_bytes)
418 );
419
420 let python_msgpack = hex::decode(
422 "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
423 )
424 .unwrap();
425 println!(
426 "Python MsgPack bytes ({}): {}",
427 python_msgpack.len(),
428 hex::encode(&python_msgpack)
429 );
430
431 assert_eq!(
433 hex::encode(&action_bytes),
434 hex::encode(&python_msgpack),
435 "MsgPack bytes should match Python"
436 );
437
438 let request = SignRequest {
440 action: None,
441 action_bytes: Some(action_bytes),
442 time_nonce: TimeNonce::from_millis(1640995200000),
443 action_type: HyperliquidActionType::L1,
444 is_testnet: true, vault_address: None,
446 expires_after: None,
447 };
448
449 let connection_id = signer.compute_connection_id(&request).unwrap();
450 println!(
451 "Rust Connection ID: {}",
452 hex::encode(connection_id.as_slice())
453 );
454
455 let expected_connection_id =
457 "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
458 assert_eq!(
459 hex::encode(connection_id.as_slice()),
460 expected_connection_id,
461 "Connection ID should match Python"
462 );
463
464 let source = "b".to_string(); let agent = Agent {
473 source,
474 connectionId: connection_id,
475 };
476
477 let domain = eip712_domain! {
478 name: "Exchange",
479 version: "1",
480 chain_id: 1337,
481 verifying_contract: Address::ZERO,
482 };
483
484 let signing_hash = agent.eip712_signing_hash(&domain);
485 println!(
486 "Rust EIP-712 signing hash: {}",
487 hex::encode(signing_hash.as_slice())
488 );
489
490 let expected_signing_hash =
492 "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
493 assert_eq!(
494 hex::encode(signing_hash.as_slice()),
495 expected_signing_hash,
496 "EIP-712 signing hash should match Python"
497 );
498 }
499
500 #[rstest]
501 fn test_connection_id_includes_expires_after_when_present() {
502 let private_key = EvmPrivateKey::new(
503 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
504 )
505 .unwrap();
506 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
507
508 let typed_action = HyperliquidExchangeAction::Order {
509 orders: vec![HyperliquidExchangePlaceOrderRequest {
510 asset: 0,
511 is_buy: true,
512 price: dec!(50000),
513 size: dec!(0.1),
514 reduce_only: false,
515 kind: HyperliquidExchangeOrderKind::Limit {
516 limit: HyperliquidExchangeLimitParams {
517 tif: HyperliquidExchangeTif::Gtc,
518 },
519 },
520 cloid: None,
521 }],
522 grouping: HyperliquidExchangeGrouping::Na,
523 builder: None,
524 };
525 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
526
527 let without_expiry = SignRequest {
528 action: None,
529 action_bytes: Some(action_bytes),
530 time_nonce: TimeNonce::from_millis(1640995200000),
531 action_type: HyperliquidActionType::L1,
532 is_testnet: true,
533 vault_address: None,
534 expires_after: None,
535 };
536 let with_expiry = SignRequest {
537 expires_after: Some(1640995260000),
538 ..without_expiry.clone()
539 };
540
541 let without_expiry_id = signer.compute_connection_id(&without_expiry).unwrap();
542 let with_expiry_id = signer.compute_connection_id(&with_expiry).unwrap();
543
544 assert_ne!(
545 without_expiry_id, with_expiry_id,
546 "expiresAfter must be part of the L1 action hash",
547 );
548 }
549
550 #[rstest]
551 fn test_connection_id_with_vault_matches_reference() {
552 let private_key = EvmPrivateKey::new(
553 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
554 )
555 .unwrap();
556 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
557
558 let typed_action = HyperliquidExchangeAction::Order {
559 orders: vec![HyperliquidExchangePlaceOrderRequest {
560 asset: 0,
561 is_buy: true,
562 price: dec!(50000),
563 size: dec!(0.1),
564 reduce_only: false,
565 kind: HyperliquidExchangeOrderKind::Limit {
566 limit: HyperliquidExchangeLimitParams {
567 tif: HyperliquidExchangeTif::Gtc,
568 },
569 },
570 cloid: None,
571 }],
572 grouping: HyperliquidExchangeGrouping::Na,
573 builder: None,
574 };
575 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
576 let request = SignRequest {
577 action: None,
578 action_bytes: Some(action_bytes),
579 time_nonce: TimeNonce::from_millis(1640995200000),
580 action_type: HyperliquidActionType::L1,
581 is_testnet: true,
582 vault_address: Some(
583 VaultAddress::parse("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01").unwrap(),
584 ),
585 expires_after: None,
586 };
587
588 let connection_id = signer.compute_connection_id(&request).unwrap();
589
590 assert_eq!(
591 hex::encode(connection_id.as_slice()),
592 "edc33e36cec99166e20ea113da7e7b028cb94efda22813f814752d719a272757",
593 "connection ID must match the L1 vault signing reference",
594 );
595 }
596
597 #[rstest]
598 fn test_connection_id_with_cloid() {
599 let private_key = EvmPrivateKey::new(
603 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
604 )
605 .unwrap();
606 let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
607
608 let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
610 println!("Cloid hex: {}", cloid.to_hex());
611
612 let typed_action = HyperliquidExchangeAction::Order {
613 orders: vec![HyperliquidExchangePlaceOrderRequest {
614 asset: 0,
615 is_buy: true,
616 price: dec!(50000),
617 size: dec!(0.1),
618 reduce_only: false,
619 kind: HyperliquidExchangeOrderKind::Limit {
620 limit: HyperliquidExchangeLimitParams {
621 tif: HyperliquidExchangeTif::Gtc,
622 },
623 },
624 cloid: Some(cloid),
625 }],
626 grouping: HyperliquidExchangeGrouping::Na,
627 builder: None,
628 };
629
630 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
632 println!(
633 "Rust MsgPack bytes with cloid ({}): {}",
634 action_bytes.len(),
635 hex::encode(&action_bytes)
636 );
637
638 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
640 println!(
641 "Decoded structure: {}",
642 serde_json::to_string_pretty(&decoded).unwrap()
643 );
644
645 let orders = decoded.get("orders").unwrap().as_array().unwrap();
647 let first_order = &orders[0];
648 let cloid_field = first_order.get("c").unwrap();
649 println!("Cloid in msgpack: {cloid_field}");
650 assert_eq!(
651 cloid_field.as_str().unwrap(),
652 "0x1234567890abcdef1234567890abcdef"
653 );
654
655 let order_json = serde_json::to_string(first_order).unwrap();
657 println!("Order JSON: {order_json}");
658 }
659
660 #[rstest]
661 fn test_cloid_from_client_order_id_is_deterministic() {
662 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
663 let other_client_order_id = ClientOrderId::from("O-20241210-123456-001-001-2");
664 let first = Cloid::from_client_order_id(client_order_id);
665 let second = Cloid::from_client_order_id(client_order_id);
666 let other = Cloid::from_client_order_id(other_client_order_id);
667
668 let first_hex = first.to_hex();
669 let second_hex = second.to_hex();
670 let other_hex = other.to_hex();
671
672 for hex in [&first_hex, &second_hex, &other_hex] {
673 assert!(hex.starts_with("0x"));
674 assert_eq!(hex.len(), 34);
675 assert!(hex[2..].chars().all(|c| c.is_ascii_hexdigit()));
676 assert!(hex[2..].chars().all(|c| !c.is_ascii_uppercase()));
677 }
678
679 assert_eq!(first_hex, "0x7824fcada984a4aa731780e8326c1932");
680 assert_eq!(other_hex, "0x9012504833e63da1435c32e96ef8b873");
681 assert_eq!(first, second);
682 assert_ne!(first, other);
683 }
684
685 #[rstest]
686 fn test_cloid_from_client_order_id_has_varied_leading_bytes() {
687 let cloids: Vec<_> = (0..100)
688 .map(|i| {
689 let client_order_id = ClientOrderId::from(format!("O-SAMPLE-{i:03}").as_str());
690 Cloid::from_client_order_id(client_order_id)
691 })
692 .collect();
693
694 let leading_bytes = cloids
695 .iter()
696 .map(|cloid| cloid.0[0])
697 .collect::<AHashSet<_>>();
698
699 let uuid_like = cloids.iter().filter(|cloid| cloid.is_uuid_v4()).count();
700 assert!(uuid_like < cloids.len());
701 assert!(leading_bytes.len() > 1);
702
703 let unique = cloids.iter().collect::<AHashSet<_>>();
704 assert_eq!(unique.len(), cloids.len());
705 }
706
707 #[rstest]
708 fn test_production_like_order_with_deterministic_cloid() {
709 let private_key = EvmPrivateKey::new(
710 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
711 )
712 .unwrap();
713 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
714
715 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
717 let cloid = Cloid::from_client_order_id(client_order_id);
718
719 println!("=== Production-like Order ===");
720 println!("ClientOrderId: {client_order_id}");
721 println!("Cloid: {}", cloid.to_hex());
722
723 let typed_action = HyperliquidExchangeAction::Order {
724 orders: vec![HyperliquidExchangePlaceOrderRequest {
725 asset: 3, is_buy: true,
727 price: dec!(92572.0),
728 size: dec!(0.001),
729 reduce_only: false,
730 kind: HyperliquidExchangeOrderKind::Limit {
731 limit: HyperliquidExchangeLimitParams {
732 tif: HyperliquidExchangeTif::Gtc,
733 },
734 },
735 cloid: Some(cloid),
736 }],
737 grouping: HyperliquidExchangeGrouping::Na,
738 builder: None,
739 };
740
741 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
743 println!(
744 "MsgPack bytes ({}): {}",
745 action_bytes.len(),
746 hex::encode(&action_bytes)
747 );
748
749 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
751 println!(
752 "Decoded: {}",
753 serde_json::to_string_pretty(&decoded).unwrap()
754 );
755
756 let request = SignRequest {
758 action: None,
759 action_bytes: Some(action_bytes),
760 time_nonce: TimeNonce::from_millis(1733833200000), action_type: HyperliquidActionType::L1,
762 is_testnet: true, vault_address: None,
764 expires_after: None,
765 };
766
767 let connection_id = signer.compute_connection_id(&request).unwrap();
768 println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
769
770 let source = "b".to_string();
772 let agent = Agent {
773 source,
774 connectionId: connection_id,
775 };
776
777 let domain = eip712_domain! {
778 name: "Exchange",
779 version: "1",
780 chain_id: 1337,
781 verifying_contract: Address::ZERO,
782 };
783
784 let signing_hash = agent.eip712_signing_hash(&domain);
785 println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
786
787 let result = signer.sign(&request).unwrap();
789 let sig_hex = result.signature.to_hex();
790 println!("Signature: {}", sig_hex.expose_secret());
791 assert!(sig_hex.expose_secret().starts_with("0x"));
792 assert_eq!(sig_hex.expose_secret().len(), 132);
793 }
794
795 #[rstest]
796 fn test_price_decimal_formatting() {
797 let test_cases = [
800 (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"), ];
806
807 for (value, precision, expected_normalized) in test_cases {
808 let price = Price::new(value, precision);
809 let price_decimal = price.as_decimal();
810 let normalized = price_decimal.normalize();
811
812 println!(
813 "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
814 );
815
816 assert_eq!(
817 normalized.to_string(),
818 expected_normalized,
819 "Price({value}, {precision}) should normalize to {expected_normalized}"
820 );
821 }
822
823 let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
825 let price_from_dec = dec!(92572.0).normalize();
826 assert_eq!(
827 price_from_type.to_string(),
828 price_from_dec.to_string(),
829 "Price::as_decimal should match dec! macro"
830 );
831 }
832}