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, HyperliquidExchangeAction, HyperliquidExchangeGrouping,
216 HyperliquidExchangeLimitParams, HyperliquidExchangeOrderKind,
217 HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeTif,
218 };
219
220 #[rstest]
221 fn test_sign_request_l1_action() {
222 let private_key = EvmPrivateKey::new(
223 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
224 )
225 .unwrap();
226 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
227
228 let request = SignRequest {
229 action: Some(json!({
230 "type": "withdraw",
231 "destination": "0xABCDEF123456789",
232 "amount": "100.000"
233 })),
234 action_bytes: None,
235 time_nonce: TimeNonce::from_millis(1640995200000),
236 action_type: HyperliquidActionType::L1,
237 is_testnet: false,
238 vault_address: None,
239 expires_after: None,
240 };
241
242 let result = signer.sign(&request).unwrap();
243 let sig_hex = result.signature.to_hex();
244 assert!(sig_hex.starts_with("0x"));
246 assert_eq!(sig_hex.len(), 132); }
248
249 #[rstest]
251 fn test_sign_l1_rejects_when_action_and_bytes_missing() {
252 let private_key = EvmPrivateKey::new(
253 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
254 )
255 .unwrap();
256 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
257
258 let request = SignRequest {
259 action: None,
260 action_bytes: None,
261 time_nonce: TimeNonce::from_millis(1640995200000),
262 action_type: HyperliquidActionType::L1,
263 is_testnet: false,
264 vault_address: None,
265 expires_after: None,
266 };
267
268 let err = signer.sign(&request).unwrap_err();
269 assert!(
270 matches!(err, Error::BadRequest(_)),
271 "expected BadRequest, was {err:?}",
272 );
273 }
274
275 #[rstest]
276 fn official_l1_dummy_action_signature_matches_python_sdk_for_both_environments() {
277 #[derive(Serialize)]
280 struct DummyAction<'a> {
281 #[serde(rename = "type")]
282 action_type: &'a str,
283 num: u64,
284 }
285
286 let python_quantity_hex = |value: &str| {
287 let digits = value.trim_start_matches("0x").trim_start_matches('0');
288 format!("0x{}", if digits.is_empty() { "0" } else { digits })
289 };
290
291 let private_key = EvmPrivateKey::new(
292 "0x0123456789012345678901234567890123456789012345678901234567890123",
293 )
294 .unwrap();
295 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
296 let action_bytes = rmp_serde::to_vec_named(&DummyAction {
297 action_type: "dummy",
298 num: 100_000_000_000,
299 })
300 .unwrap();
301 let request = |is_testnet| SignRequest {
302 action: None,
303 action_bytes: Some(action_bytes.clone()),
304 time_nonce: TimeNonce::from_millis(0),
305 action_type: HyperliquidActionType::L1,
306 is_testnet,
307 vault_address: None,
308 expires_after: None,
309 };
310
311 let mainnet = signer.sign_l1_action(&request(false)).unwrap();
312 assert_eq!(
313 python_quantity_hex(&mainnet.r),
314 "0x53749d5b30552aeb2fca34b530185976545bb22d0b3ce6f62e31be961a59298"
315 );
316 assert_eq!(
317 mainnet.s,
318 "0x755c40ba9bf05223521753995abb2f73ab3229be8ec921f350cb447e384d8ed8"
319 );
320 assert_eq!(mainnet.v, 27);
321
322 let testnet = signer.sign_l1_action(&request(true)).unwrap();
323 assert_eq!(
324 testnet.r,
325 "0x542af61ef1f429707e3c76c5293c80d01f74ef853e34b76efffcb57e574f9510"
326 );
327 assert_eq!(
328 testnet.s,
329 "0x17b8b32f086e8cdede991f1e2c529f5dd5297cbe8128500e00cbaf766204a613"
330 );
331 assert_eq!(testnet.v, 28);
332 }
333
334 #[rstest]
335 fn test_sign_user_signed_returns_error() {
336 let private_key = EvmPrivateKey::new(
337 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
338 )
339 .unwrap();
340 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
341
342 let request = SignRequest {
343 action: Some(json!({"type": "order"})),
344 action_bytes: None,
345 time_nonce: TimeNonce::from_millis(1640995200000),
346 action_type: HyperliquidActionType::UserSigned,
347 is_testnet: false,
348 vault_address: None,
349 expires_after: None,
350 };
351
352 let err = signer.sign(&request).unwrap_err();
353 assert!(
354 matches!(err, Error::BadRequest(_)),
355 "expected BadRequest, was {err:?}"
356 );
357 }
358
359 #[rstest]
360 fn test_connection_id_matches_python() {
361 let private_key = EvmPrivateKey::new(
367 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
368 )
369 .unwrap();
370 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
371
372 let typed_action = HyperliquidExchangeAction::Order {
381 orders: vec![HyperliquidExchangePlaceOrderRequest {
382 asset: 0,
383 is_buy: true,
384 price: dec!(50000),
385 size: dec!(0.1),
386 reduce_only: false,
387 kind: HyperliquidExchangeOrderKind::Limit {
388 limit: HyperliquidExchangeLimitParams {
389 tif: HyperliquidExchangeTif::Gtc,
390 },
391 },
392 cloid: None,
393 }],
394 grouping: HyperliquidExchangeGrouping::Na,
395 builder: None,
396 };
397
398 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
400 println!(
401 "Rust typed MsgPack bytes ({}): {}",
402 action_bytes.len(),
403 hex::encode(&action_bytes)
404 );
405
406 let python_msgpack = hex::decode(
408 "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
409 )
410 .unwrap();
411 println!(
412 "Python MsgPack bytes ({}): {}",
413 python_msgpack.len(),
414 hex::encode(&python_msgpack)
415 );
416
417 assert_eq!(
419 hex::encode(&action_bytes),
420 hex::encode(&python_msgpack),
421 "MsgPack bytes should match Python"
422 );
423
424 let request = SignRequest {
426 action: None,
427 action_bytes: Some(action_bytes),
428 time_nonce: TimeNonce::from_millis(1640995200000),
429 action_type: HyperliquidActionType::L1,
430 is_testnet: true, vault_address: None,
432 expires_after: None,
433 };
434
435 let connection_id = signer.compute_connection_id(&request).unwrap();
436 println!(
437 "Rust Connection ID: {}",
438 hex::encode(connection_id.as_slice())
439 );
440
441 let expected_connection_id =
443 "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
444 assert_eq!(
445 hex::encode(connection_id.as_slice()),
446 expected_connection_id,
447 "Connection ID should match Python"
448 );
449
450 let source = "b".to_string(); let agent = Agent {
459 source,
460 connectionId: connection_id,
461 };
462
463 let domain = eip712_domain! {
464 name: "Exchange",
465 version: "1",
466 chain_id: 1337,
467 verifying_contract: Address::ZERO,
468 };
469
470 let signing_hash = agent.eip712_signing_hash(&domain);
471 println!(
472 "Rust EIP-712 signing hash: {}",
473 hex::encode(signing_hash.as_slice())
474 );
475
476 let expected_signing_hash =
478 "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
479 assert_eq!(
480 hex::encode(signing_hash.as_slice()),
481 expected_signing_hash,
482 "EIP-712 signing hash should match Python"
483 );
484 }
485
486 #[rstest]
487 fn test_connection_id_includes_expires_after_when_present() {
488 let private_key = EvmPrivateKey::new(
489 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
490 )
491 .unwrap();
492 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
493
494 let typed_action = HyperliquidExchangeAction::Order {
495 orders: vec![HyperliquidExchangePlaceOrderRequest {
496 asset: 0,
497 is_buy: true,
498 price: dec!(50000),
499 size: dec!(0.1),
500 reduce_only: false,
501 kind: HyperliquidExchangeOrderKind::Limit {
502 limit: HyperliquidExchangeLimitParams {
503 tif: HyperliquidExchangeTif::Gtc,
504 },
505 },
506 cloid: None,
507 }],
508 grouping: HyperliquidExchangeGrouping::Na,
509 builder: None,
510 };
511 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
512
513 let without_expiry = SignRequest {
514 action: None,
515 action_bytes: Some(action_bytes),
516 time_nonce: TimeNonce::from_millis(1640995200000),
517 action_type: HyperliquidActionType::L1,
518 is_testnet: true,
519 vault_address: None,
520 expires_after: None,
521 };
522 let with_expiry = SignRequest {
523 expires_after: Some(1640995260000),
524 ..without_expiry.clone()
525 };
526
527 let without_expiry_id = signer.compute_connection_id(&without_expiry).unwrap();
528 let with_expiry_id = signer.compute_connection_id(&with_expiry).unwrap();
529
530 assert_ne!(
531 without_expiry_id, with_expiry_id,
532 "expiresAfter must be part of the L1 action hash",
533 );
534 }
535
536 #[rstest]
537 fn test_connection_id_with_vault_matches_reference() {
538 let private_key = EvmPrivateKey::new(
539 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
540 )
541 .unwrap();
542 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
543
544 let typed_action = HyperliquidExchangeAction::Order {
545 orders: vec![HyperliquidExchangePlaceOrderRequest {
546 asset: 0,
547 is_buy: true,
548 price: dec!(50000),
549 size: dec!(0.1),
550 reduce_only: false,
551 kind: HyperliquidExchangeOrderKind::Limit {
552 limit: HyperliquidExchangeLimitParams {
553 tif: HyperliquidExchangeTif::Gtc,
554 },
555 },
556 cloid: None,
557 }],
558 grouping: HyperliquidExchangeGrouping::Na,
559 builder: None,
560 };
561 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
562 let request = SignRequest {
563 action: None,
564 action_bytes: Some(action_bytes),
565 time_nonce: TimeNonce::from_millis(1640995200000),
566 action_type: HyperliquidActionType::L1,
567 is_testnet: true,
568 vault_address: Some(
569 VaultAddress::parse("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01").unwrap(),
570 ),
571 expires_after: None,
572 };
573
574 let connection_id = signer.compute_connection_id(&request).unwrap();
575
576 assert_eq!(
577 hex::encode(connection_id.as_slice()),
578 "edc33e36cec99166e20ea113da7e7b028cb94efda22813f814752d719a272757",
579 "connection ID must match the L1 vault signing reference",
580 );
581 }
582
583 #[rstest]
584 fn test_connection_id_with_cloid() {
585 let private_key = EvmPrivateKey::new(
589 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
590 )
591 .unwrap();
592 let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
593
594 let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
596 println!("Cloid hex: {}", cloid.to_hex());
597
598 let typed_action = HyperliquidExchangeAction::Order {
599 orders: vec![HyperliquidExchangePlaceOrderRequest {
600 asset: 0,
601 is_buy: true,
602 price: dec!(50000),
603 size: dec!(0.1),
604 reduce_only: false,
605 kind: HyperliquidExchangeOrderKind::Limit {
606 limit: HyperliquidExchangeLimitParams {
607 tif: HyperliquidExchangeTif::Gtc,
608 },
609 },
610 cloid: Some(cloid),
611 }],
612 grouping: HyperliquidExchangeGrouping::Na,
613 builder: None,
614 };
615
616 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
618 println!(
619 "Rust MsgPack bytes with cloid ({}): {}",
620 action_bytes.len(),
621 hex::encode(&action_bytes)
622 );
623
624 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
626 println!(
627 "Decoded structure: {}",
628 serde_json::to_string_pretty(&decoded).unwrap()
629 );
630
631 let orders = decoded.get("orders").unwrap().as_array().unwrap();
633 let first_order = &orders[0];
634 let cloid_field = first_order.get("c").unwrap();
635 println!("Cloid in msgpack: {cloid_field}");
636 assert_eq!(
637 cloid_field.as_str().unwrap(),
638 "0x1234567890abcdef1234567890abcdef"
639 );
640
641 let order_json = serde_json::to_string(first_order).unwrap();
643 println!("Order JSON: {order_json}");
644 }
645
646 #[rstest]
647 fn test_cloid_from_client_order_id_is_deterministic() {
648 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
649 let other_client_order_id = ClientOrderId::from("O-20241210-123456-001-001-2");
650 let first = Cloid::from_client_order_id(client_order_id);
651 let second = Cloid::from_client_order_id(client_order_id);
652 let other = Cloid::from_client_order_id(other_client_order_id);
653
654 let first_hex = first.to_hex();
655 let second_hex = second.to_hex();
656 let other_hex = other.to_hex();
657
658 for hex in [&first_hex, &second_hex, &other_hex] {
659 assert!(hex.starts_with("0x"));
660 assert_eq!(hex.len(), 34);
661 assert!(hex[2..].chars().all(|c| c.is_ascii_hexdigit()));
662 assert!(hex[2..].chars().all(|c| !c.is_ascii_uppercase()));
663 }
664
665 assert_eq!(first_hex, "0x7824fcada984a4aa731780e8326c1932");
666 assert_eq!(other_hex, "0x9012504833e63da1435c32e96ef8b873");
667 assert_eq!(first, second);
668 assert_ne!(first, other);
669 }
670
671 #[rstest]
672 fn test_cloid_from_client_order_id_has_varied_leading_bytes() {
673 let cloids: Vec<_> = (0..100)
674 .map(|i| {
675 let client_order_id = ClientOrderId::from(format!("O-SAMPLE-{i:03}").as_str());
676 Cloid::from_client_order_id(client_order_id)
677 })
678 .collect();
679
680 let leading_bytes = cloids
681 .iter()
682 .map(|cloid| cloid.0[0])
683 .collect::<AHashSet<_>>();
684
685 let uuid_like = cloids.iter().filter(|cloid| cloid.is_uuid_v4()).count();
686 assert!(uuid_like < cloids.len());
687 assert!(leading_bytes.len() > 1);
688
689 let unique = cloids.iter().collect::<AHashSet<_>>();
690 assert_eq!(unique.len(), cloids.len());
691 }
692
693 #[rstest]
694 fn test_production_like_order_with_deterministic_cloid() {
695 let private_key = EvmPrivateKey::new(
696 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
697 )
698 .unwrap();
699 let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
700
701 let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
703 let cloid = Cloid::from_client_order_id(client_order_id);
704
705 println!("=== Production-like Order ===");
706 println!("ClientOrderId: {client_order_id}");
707 println!("Cloid: {}", cloid.to_hex());
708
709 let typed_action = HyperliquidExchangeAction::Order {
710 orders: vec![HyperliquidExchangePlaceOrderRequest {
711 asset: 3, is_buy: true,
713 price: dec!(92572.0),
714 size: dec!(0.001),
715 reduce_only: false,
716 kind: HyperliquidExchangeOrderKind::Limit {
717 limit: HyperliquidExchangeLimitParams {
718 tif: HyperliquidExchangeTif::Gtc,
719 },
720 },
721 cloid: Some(cloid),
722 }],
723 grouping: HyperliquidExchangeGrouping::Na,
724 builder: None,
725 };
726
727 let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
729 println!(
730 "MsgPack bytes ({}): {}",
731 action_bytes.len(),
732 hex::encode(&action_bytes)
733 );
734
735 let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
737 println!(
738 "Decoded: {}",
739 serde_json::to_string_pretty(&decoded).unwrap()
740 );
741
742 let request = SignRequest {
744 action: None,
745 action_bytes: Some(action_bytes),
746 time_nonce: TimeNonce::from_millis(1733833200000), action_type: HyperliquidActionType::L1,
748 is_testnet: true, vault_address: None,
750 expires_after: None,
751 };
752
753 let connection_id = signer.compute_connection_id(&request).unwrap();
754 println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
755
756 let source = "b".to_string();
758 let agent = Agent {
759 source,
760 connectionId: connection_id,
761 };
762
763 let domain = eip712_domain! {
764 name: "Exchange",
765 version: "1",
766 chain_id: 1337,
767 verifying_contract: Address::ZERO,
768 };
769
770 let signing_hash = agent.eip712_signing_hash(&domain);
771 println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
772
773 let result = signer.sign(&request).unwrap();
775 let sig_hex = result.signature.to_hex();
776 println!("Signature: {sig_hex}");
777 assert!(sig_hex.starts_with("0x"));
778 assert_eq!(sig_hex.len(), 132);
779 }
780
781 #[rstest]
782 fn test_price_decimal_formatting() {
783 let test_cases = [
786 (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"), ];
792
793 for (value, precision, expected_normalized) in test_cases {
794 let price = Price::new(value, precision);
795 let price_decimal = price.as_decimal();
796 let normalized = price_decimal.normalize();
797
798 println!(
799 "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
800 );
801
802 assert_eq!(
803 normalized.to_string(),
804 expected_normalized,
805 "Price({value}, {precision}) should normalize to {expected_normalized}"
806 );
807 }
808
809 let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
811 let price_from_dec = dec!(92572.0).normalize();
812 assert_eq!(
813 price_from_type.to_string(),
814 price_from_dec.to_string(),
815 "Price::as_decimal should match dec! macro"
816 );
817 }
818}