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::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
35// Define the Agent struct for L1 signing
36alloy::sol! {
37    #[derive(Debug, Serialize, Deserialize)]
38    struct Agent {
39        string source;
40        bytes32 connectionId;
41    }
42}
43
44/// Request to be signed by the Hyperliquid EIP-712 signer.
45///
46/// For L1 actions, populate `action_bytes` with the pre-serialized MessagePack
47/// of the typed action; `action` may be `None`. The `action` JSON value is only
48/// consumed as a fallback when `action_bytes` is `None` (kept for ad-hoc test
49/// payloads built via `json!`).
50#[derive(Debug, Clone)]
51pub struct SignRequest {
52    pub action: Option<Value>,         // Fallback when action_bytes is None
53    pub action_bytes: Option<Vec<u8>>, // Pre-serialized MessagePack (preferred)
54    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/// Bundle containing signature for Hyperliquid requests.
62#[derive(Debug, Clone)]
63pub struct SignatureBundle {
64    pub signature: HyperliquidSignature,
65}
66
67/// EIP-712 signer for Hyperliquid.
68#[derive(Debug, Clone)]
69pub struct HyperliquidEip712Signer {
70    signer: PrivateKeySigner,
71    address: String,
72    domain: Eip712Domain,
73}
74
75impl HyperliquidEip712Signer {
76    /// Creates a new [`HyperliquidEip712Signer`].
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if the private key cannot be parsed.
81    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        // L1 signing for Hyperliquid follows this pattern:
119        // 1. Serialize action with MessagePack (rmp_serde)
120        // 2. Append timestamp, vault info, and optional expiry
121        // 3. Hash with keccak256 to get connection_id
122        // 4. Create Agent struct with source + connection_id
123        // 5. Sign Agent with EIP-712
124
125        // Step 1-3: Create connection_id
126        let connection_id = self.compute_connection_id(request)?;
127
128        // Step 4: Create Agent struct
129        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        // Step 5: Sign Agent with EIP-712
137        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    /// Returns the signer's Ethereum address.
198    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        // Verify signature format: 0x + 64 hex chars (r) + 64 hex chars (s) + 2 hex chars (v)
245        assert!(sig_hex.starts_with("0x"));
246        assert_eq!(sig_hex.len(), 132); // 0x + 130 hex chars
247    }
248
249    // L1 sign with neither field set must error, not panic on missing input
250    #[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        // Official L1 vector from hyperliquid-python-sdk tests/signing_test.py
278        // (revision 2fdb18f9517675ea03695a0962bd19eece9c83f0).
279        #[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        // Test that our connection_id computation matches Python SDK exactly.
362        // Python expected output for this test case:
363        // MsgPack bytes: 83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61
364        // Connection ID: 207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40
365
366        let private_key = EvmPrivateKey::new(
367            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
368        )
369        .unwrap();
370        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
371
372        // NOTE: json! macro sorts keys alphabetically, but Python preserves insertion order.
373        // Field order: Python uses "type", "orders", "grouping"
374        // json! produces: "grouping", "orders", "type" (alphabetical)
375        // This causes hash mismatch!
376        //
377        // When using typed structs (HyperliquidExchangeAction), serde follows declaration order.
378        // Let's test with the typed struct approach.
379
380        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        // Serialize the typed struct with msgpack
399        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        // Expected from Python
407        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        // Compare msgpack bytes
418        assert_eq!(
419            hex::encode(&action_bytes),
420            hex::encode(&python_msgpack),
421            "MsgPack bytes should match Python"
422        );
423
424        // Now test the full connection_id computation
425        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, // source = "b"
431            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        // Expected from Python
442        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        // Now test the full signing hash
451        // Python expected values:
452        // Domain separator: d79297fcdf2ffcd4ae223d01edaa2ba214ff8f401d7c9300d995d17c82aa4040
453        // Struct hash: 99c7d776d74816c42973fbe58bb0f0d03c80324bef180220196d0dccf01672c5
454        // Signing hash: 5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d
455
456        // Create Agent and sign - matching our sign_l1_action logic
457        let source = "b".to_string(); // is_testnet = true
458        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        // Expected from Python
477        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        // Test with CLOID included - this is what production actually sends.
586        // The key difference: production always includes a cloid field.
587
588        let private_key = EvmPrivateKey::new(
589            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
590        )
591        .unwrap();
592        let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
593
594        // Create a cloid - this is how Python SDK expects it
595        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        // Serialize the typed struct with msgpack
617        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        // Decode to see the structure
625        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        // Verify the cloid is in the right place
632        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        // Verify order field order is correct: a, b, p, s, r, t, c
642        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        // Production-like values
702        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, // BTC on testnet
712                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        // Serialize with msgpack
728        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        // Decode to verify structure
736        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        // Compute connection_id and signing hash
743        let request = SignRequest {
744            action: None,
745            action_bytes: Some(action_bytes),
746            time_nonce: TimeNonce::from_millis(1733833200000), // Dec 10, 2024
747            action_type: HyperliquidActionType::L1,
748            is_testnet: true, // source = "b"
749            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        // Create Agent and get signing hash
757        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        // Sign and verify signature format
774        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        // Compare how Price::as_decimal() formats vs dec!() macro
784        // Test various price formats
785        let test_cases = [
786            (92572.0_f64, 1_u8, "92572"), // BTC price
787            (92572.5, 1, "92572.5"),      // BTC price with fractional
788            (0.001, 8, "0.001"),          // Small qty
789            (50000.0, 1, "50000"),        // Round number
790            (0.1, 4, "0.1"),              // Typical qty
791        ];
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        // Verify dec! macro produces same result
810        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}