Skip to main content

nautilus_hyperliquid/signing/
signers.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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
36// Define the Agent struct for L1 signing
37alloy::sol! {
38    #[derive(Debug, Serialize, Deserialize)]
39    struct Agent {
40        string source;
41        bytes32 connectionId;
42    }
43}
44
45/// Request to be signed by the Hyperliquid EIP-712 signer.
46///
47/// For L1 actions, populate `action_bytes` with the pre-serialized MessagePack
48/// of the typed action; `action` may be `None`. The `action` JSON value is only
49/// consumed as a fallback when `action_bytes` is `None` (kept for ad-hoc test
50/// payloads built via `json!`).
51#[derive(Debug, Clone)]
52pub struct SignRequest {
53    pub action: Option<Value>,         // Fallback when action_bytes is None
54    pub action_bytes: Option<Vec<u8>>, // Pre-serialized MessagePack (preferred)
55    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/// Bundle containing signature for Hyperliquid requests.
63#[derive(Debug, Clone)]
64pub struct SignatureBundle {
65    pub signature: HyperliquidSignature,
66}
67
68/// EIP-712 signer for Hyperliquid.
69#[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    /// Creates a new [`HyperliquidEip712Signer`].
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the private key cannot be parsed.
92    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        // L1 signing for Hyperliquid follows this pattern:
130        // 1. Serialize action with MessagePack (rmp_serde)
131        // 2. Append timestamp, vault info, and optional expiry
132        // 3. Hash with keccak256 to get connection_id
133        // 4. Create Agent struct with source + connection_id
134        // 5. Sign Agent with EIP-712
135
136        // Step 1-3: Create connection_id
137        let connection_id = self.compute_connection_id(request)?;
138
139        // Step 4: Create Agent struct
140        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        // Step 5: Sign Agent with EIP-712
148        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    /// Returns the signer's Ethereum address.
209    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        // Verify signature format: 0x + 64 hex chars (r) + 64 hex chars (s) + 2 hex chars (v)
257        assert!(sig_hex.expose_secret().starts_with("0x"));
258        assert_eq!(sig_hex.expose_secret().len(), 132); // 0x + 130 hex chars
259        assert!(debug.contains(REDACTED));
260        assert!(!debug.contains(private_key.as_hex()));
261    }
262
263    // L1 sign with neither field set must error, not panic on missing input
264    #[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        // Official L1 vector from hyperliquid-python-sdk tests/signing_test.py
292        // (revision 2fdb18f9517675ea03695a0962bd19eece9c83f0).
293        #[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        // Test that our connection_id computation matches Python SDK exactly.
376        // Python expected output for this test case:
377        // MsgPack bytes: 83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61
378        // Connection ID: 207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40
379
380        let private_key = EvmPrivateKey::new(
381            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
382        )
383        .unwrap();
384        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
385
386        // NOTE: json! macro sorts keys alphabetically, but Python preserves insertion order.
387        // Field order: Python uses "type", "orders", "grouping"
388        // json! produces: "grouping", "orders", "type" (alphabetical)
389        // This causes hash mismatch!
390        //
391        // When using typed structs (HyperliquidExchangeAction), serde follows declaration order.
392        // Let's test with the typed struct approach.
393
394        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        // Serialize the typed struct with msgpack
413        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        // Expected from Python
421        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        // Compare msgpack bytes
432        assert_eq!(
433            hex::encode(&action_bytes),
434            hex::encode(&python_msgpack),
435            "MsgPack bytes should match Python"
436        );
437
438        // Now test the full connection_id computation
439        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, // source = "b"
445            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        // Expected from Python
456        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        // Now test the full signing hash
465        // Python expected values:
466        // Domain separator: d79297fcdf2ffcd4ae223d01edaa2ba214ff8f401d7c9300d995d17c82aa4040
467        // Struct hash: 99c7d776d74816c42973fbe58bb0f0d03c80324bef180220196d0dccf01672c5
468        // Signing hash: 5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d
469
470        // Create Agent and sign - matching our sign_l1_action logic
471        let source = "b".to_string(); // is_testnet = true
472        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        // Expected from Python
491        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        // Test with CLOID included - this is what production actually sends.
600        // The key difference: production always includes a cloid field.
601
602        let private_key = EvmPrivateKey::new(
603            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
604        )
605        .unwrap();
606        let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
607
608        // Create a cloid - this is how Python SDK expects it
609        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        // Serialize the typed struct with msgpack
631        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        // Decode to see the structure
639        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        // Verify the cloid is in the right place
646        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        // Verify order field order is correct: a, b, p, s, r, t, c
656        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        // Production-like values
716        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, // BTC on testnet
726                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        // Serialize with msgpack
742        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        // Decode to verify structure
750        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        // Compute connection_id and signing hash
757        let request = SignRequest {
758            action: None,
759            action_bytes: Some(action_bytes),
760            time_nonce: TimeNonce::from_millis(1733833200000), // Dec 10, 2024
761            action_type: HyperliquidActionType::L1,
762            is_testnet: true, // source = "b"
763            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        // Create Agent and get signing hash
771        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        // Sign and verify signature format
788        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        // Compare how Price::as_decimal() formats vs dec!() macro
798        // Test various price formats
799        let test_cases = [
800            (92572.0_f64, 1_u8, "92572"), // BTC price
801            (92572.5, 1, "92572.5"),      // BTC price with fractional
802            (0.001, 8, "0.001"),          // Small qty
803            (50000.0, 1, "50000"),        // Round number
804            (0.1, 4, "0.1"),              // Typical qty
805        ];
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        // Verify dec! macro produces same result
824        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}