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    #[case("limit")]
350    #[case("trigger")]
351    #[case("batch_modify")]
352    fn test_order_decimal_signing_matches_python_sdk(
353        #[case] case_name: &str,
354        #[values(false, true)] is_testnet: bool,
355    ) {
356        let fixture: serde_json::Value =
357            serde_json::from_str(include_str!("../../test_data/order_decimal_signing.json"))
358                .unwrap();
359        let case = fixture["cases"]
360            .as_array()
361            .unwrap()
362            .iter()
363            .find(|case| case["name"] == case_name)
364            .unwrap();
365        let action: HyperliquidExchangeAction =
366            serde_json::from_value(case["input"].clone()).unwrap();
367        let action_bytes = rmp_serde::to_vec_named(&action).unwrap();
368        let key = EvmPrivateKey::new(
369            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
370        )
371        .unwrap();
372        let signer = HyperliquidEip712Signer::new(&key).unwrap();
373
374        let request = SignRequest {
375            action: None,
376            action_bytes: Some(action_bytes.clone()),
377            time_nonce: TimeNonce::from_millis(i128::from(fixture["nonce"].as_u64().unwrap())),
378            action_type: HyperliquidActionType::L1,
379            is_testnet,
380            vault_address: None,
381            expires_after: None,
382        };
383
384        let connection_id = signer.compute_connection_id(&request).unwrap();
385        let signature = signer.sign_l1_action(&request).unwrap();
386
387        let expected_signature = &case[if is_testnet {
388            "testnet_signature"
389        } else {
390            "mainnet_signature"
391        }];
392
393        let expected_r = format!(
394            "0x{:0>64}",
395            expected_signature["r"]
396                .as_str()
397                .unwrap()
398                .trim_start_matches("0x")
399        );
400        let expected_s = format!(
401            "0x{:0>64}",
402            expected_signature["s"]
403                .as_str()
404                .unwrap()
405                .trim_start_matches("0x")
406        );
407
408        assert_eq!(serde_json::to_value(&action).unwrap(), case["action"]);
409        assert_eq!(hex::encode(action_bytes), case["msgpack"].as_str().unwrap());
410        assert_eq!(
411            hex::encode(connection_id),
412            case["connection_id"].as_str().unwrap()
413        );
414        assert_eq!(signature.r.expose_secret(), expected_r);
415        assert_eq!(signature.s.expose_secret(), expected_s);
416        assert_eq!(signature.v, expected_signature["v"].as_u64().unwrap());
417    }
418
419    #[rstest]
420    fn test_sign_user_signed_returns_error() {
421        let private_key = EvmPrivateKey::new(
422            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
423        )
424        .unwrap();
425        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
426
427        let request = SignRequest {
428            action: Some(json!({"type": "order"})),
429            action_bytes: None,
430            time_nonce: TimeNonce::from_millis(1640995200000),
431            action_type: HyperliquidActionType::UserSigned,
432            is_testnet: false,
433            vault_address: None,
434            expires_after: None,
435        };
436
437        let err = signer.sign(&request).unwrap_err();
438        assert!(
439            matches!(err, Error::BadRequest(_)),
440            "expected BadRequest, was {err:?}"
441        );
442    }
443
444    #[rstest]
445    fn test_connection_id_matches_python() {
446        // Test that our connection_id computation matches Python SDK exactly.
447        // Python expected output for this test case:
448        // MsgPack bytes: 83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61
449        // Connection ID: 207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40
450
451        let private_key = EvmPrivateKey::new(
452            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
453        )
454        .unwrap();
455        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
456
457        // NOTE: json! macro sorts keys alphabetically, but Python preserves insertion order.
458        // Field order: Python uses "type", "orders", "grouping"
459        // json! produces: "grouping", "orders", "type" (alphabetical)
460        // This causes hash mismatch!
461        //
462        // When using typed structs (HyperliquidExchangeAction), serde follows declaration order.
463        // Let's test with the typed struct approach.
464
465        let typed_action = HyperliquidExchangeAction::Order {
466            orders: vec![HyperliquidExchangePlaceOrderRequest {
467                asset: 0,
468                is_buy: true,
469                price: dec!(50000),
470                size: dec!(0.1),
471                reduce_only: false,
472                kind: HyperliquidExchangeOrderKind::Limit {
473                    limit: HyperliquidExchangeLimitParams {
474                        tif: HyperliquidExchangeTif::Gtc,
475                    },
476                },
477                cloid: None,
478            }],
479            grouping: HyperliquidExchangeGrouping::Na,
480            builder: None,
481        };
482
483        // Serialize the typed struct with msgpack
484        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
485        println!(
486            "Rust typed MsgPack bytes ({}): {}",
487            action_bytes.len(),
488            hex::encode(&action_bytes)
489        );
490
491        // Expected from Python
492        let python_msgpack = hex::decode(
493            "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
494        )
495        .unwrap();
496        println!(
497            "Python MsgPack bytes ({}): {}",
498            python_msgpack.len(),
499            hex::encode(&python_msgpack)
500        );
501
502        // Compare msgpack bytes
503        assert_eq!(
504            hex::encode(&action_bytes),
505            hex::encode(&python_msgpack),
506            "MsgPack bytes should match Python"
507        );
508
509        // Now test the full connection_id computation
510        let request = SignRequest {
511            action: None,
512            action_bytes: Some(action_bytes),
513            time_nonce: TimeNonce::from_millis(1640995200000),
514            action_type: HyperliquidActionType::L1,
515            is_testnet: true, // source = "b"
516            vault_address: None,
517            expires_after: None,
518        };
519
520        let connection_id = signer.compute_connection_id(&request).unwrap();
521        println!(
522            "Rust Connection ID: {}",
523            hex::encode(connection_id.as_slice())
524        );
525
526        // Expected from Python
527        let expected_connection_id =
528            "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
529        assert_eq!(
530            hex::encode(connection_id.as_slice()),
531            expected_connection_id,
532            "Connection ID should match Python"
533        );
534
535        // Now test the full signing hash
536        // Python expected values:
537        // Domain separator: d79297fcdf2ffcd4ae223d01edaa2ba214ff8f401d7c9300d995d17c82aa4040
538        // Struct hash: 99c7d776d74816c42973fbe58bb0f0d03c80324bef180220196d0dccf01672c5
539        // Signing hash: 5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d
540
541        // Create Agent and sign - matching our sign_l1_action logic
542        let source = "b".to_string(); // is_testnet = true
543        let agent = Agent {
544            source,
545            connectionId: connection_id,
546        };
547
548        let domain = eip712_domain! {
549            name: "Exchange",
550            version: "1",
551            chain_id: 1337,
552            verifying_contract: Address::ZERO,
553        };
554
555        let signing_hash = agent.eip712_signing_hash(&domain);
556        println!(
557            "Rust EIP-712 signing hash: {}",
558            hex::encode(signing_hash.as_slice())
559        );
560
561        // Expected from Python
562        let expected_signing_hash =
563            "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
564        assert_eq!(
565            hex::encode(signing_hash.as_slice()),
566            expected_signing_hash,
567            "EIP-712 signing hash should match Python"
568        );
569    }
570
571    #[rstest]
572    fn test_connection_id_includes_expires_after_when_present() {
573        let private_key = EvmPrivateKey::new(
574            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
575        )
576        .unwrap();
577        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
578
579        let typed_action = HyperliquidExchangeAction::Order {
580            orders: vec![HyperliquidExchangePlaceOrderRequest {
581                asset: 0,
582                is_buy: true,
583                price: dec!(50000),
584                size: dec!(0.1),
585                reduce_only: false,
586                kind: HyperliquidExchangeOrderKind::Limit {
587                    limit: HyperliquidExchangeLimitParams {
588                        tif: HyperliquidExchangeTif::Gtc,
589                    },
590                },
591                cloid: None,
592            }],
593            grouping: HyperliquidExchangeGrouping::Na,
594            builder: None,
595        };
596        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
597
598        let without_expiry = SignRequest {
599            action: None,
600            action_bytes: Some(action_bytes),
601            time_nonce: TimeNonce::from_millis(1640995200000),
602            action_type: HyperliquidActionType::L1,
603            is_testnet: true,
604            vault_address: None,
605            expires_after: None,
606        };
607        let with_expiry = SignRequest {
608            expires_after: Some(1640995260000),
609            ..without_expiry.clone()
610        };
611
612        let without_expiry_id = signer.compute_connection_id(&without_expiry).unwrap();
613        let with_expiry_id = signer.compute_connection_id(&with_expiry).unwrap();
614
615        assert_ne!(
616            without_expiry_id, with_expiry_id,
617            "expiresAfter must be part of the L1 action hash",
618        );
619    }
620
621    #[rstest]
622    fn test_connection_id_with_vault_matches_reference() {
623        let private_key = EvmPrivateKey::new(
624            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
625        )
626        .unwrap();
627        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
628
629        let typed_action = HyperliquidExchangeAction::Order {
630            orders: vec![HyperliquidExchangePlaceOrderRequest {
631                asset: 0,
632                is_buy: true,
633                price: dec!(50000),
634                size: dec!(0.1),
635                reduce_only: false,
636                kind: HyperliquidExchangeOrderKind::Limit {
637                    limit: HyperliquidExchangeLimitParams {
638                        tif: HyperliquidExchangeTif::Gtc,
639                    },
640                },
641                cloid: None,
642            }],
643            grouping: HyperliquidExchangeGrouping::Na,
644            builder: None,
645        };
646        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
647        let request = SignRequest {
648            action: None,
649            action_bytes: Some(action_bytes),
650            time_nonce: TimeNonce::from_millis(1640995200000),
651            action_type: HyperliquidActionType::L1,
652            is_testnet: true,
653            vault_address: Some(
654                VaultAddress::parse("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01").unwrap(),
655            ),
656            expires_after: None,
657        };
658
659        let connection_id = signer.compute_connection_id(&request).unwrap();
660
661        assert_eq!(
662            hex::encode(connection_id.as_slice()),
663            "edc33e36cec99166e20ea113da7e7b028cb94efda22813f814752d719a272757",
664            "connection ID must match the L1 vault signing reference",
665        );
666    }
667
668    #[rstest]
669    fn test_connection_id_with_cloid() {
670        // Test with CLOID included - this is what production actually sends.
671        // The key difference: production always includes a cloid field.
672
673        let private_key = EvmPrivateKey::new(
674            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
675        )
676        .unwrap();
677        let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
678
679        // Create a cloid - this is how Python SDK expects it
680        let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
681        println!("Cloid hex: {}", cloid.to_hex());
682
683        let typed_action = HyperliquidExchangeAction::Order {
684            orders: vec![HyperliquidExchangePlaceOrderRequest {
685                asset: 0,
686                is_buy: true,
687                price: dec!(50000),
688                size: dec!(0.1),
689                reduce_only: false,
690                kind: HyperliquidExchangeOrderKind::Limit {
691                    limit: HyperliquidExchangeLimitParams {
692                        tif: HyperliquidExchangeTif::Gtc,
693                    },
694                },
695                cloid: Some(cloid),
696            }],
697            grouping: HyperliquidExchangeGrouping::Na,
698            builder: None,
699        };
700
701        // Serialize the typed struct with msgpack
702        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
703        println!(
704            "Rust MsgPack bytes with cloid ({}): {}",
705            action_bytes.len(),
706            hex::encode(&action_bytes)
707        );
708
709        // Decode to see the structure
710        let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
711        println!(
712            "Decoded structure: {}",
713            serde_json::to_string_pretty(&decoded).unwrap()
714        );
715
716        // Verify the cloid is in the right place
717        let orders = decoded.get("orders").unwrap().as_array().unwrap();
718        let first_order = &orders[0];
719        let cloid_field = first_order.get("c").unwrap();
720        println!("Cloid in msgpack: {cloid_field}");
721        assert_eq!(
722            cloid_field.as_str().unwrap(),
723            "0x1234567890abcdef1234567890abcdef"
724        );
725
726        // Verify order field order is correct: a, b, p, s, r, t, c
727        let order_json = serde_json::to_string(first_order).unwrap();
728        println!("Order JSON: {order_json}");
729    }
730
731    #[rstest]
732    fn test_cloid_from_client_order_id_is_deterministic() {
733        let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
734        let other_client_order_id = ClientOrderId::from("O-20241210-123456-001-001-2");
735        let first = Cloid::from_client_order_id(client_order_id);
736        let second = Cloid::from_client_order_id(client_order_id);
737        let other = Cloid::from_client_order_id(other_client_order_id);
738
739        let first_hex = first.to_hex();
740        let second_hex = second.to_hex();
741        let other_hex = other.to_hex();
742
743        for hex in [&first_hex, &second_hex, &other_hex] {
744            assert!(hex.starts_with("0x"));
745            assert_eq!(hex.len(), 34);
746            assert!(hex[2..].chars().all(|c| c.is_ascii_hexdigit()));
747            assert!(hex[2..].chars().all(|c| !c.is_ascii_uppercase()));
748        }
749
750        assert_eq!(first_hex, "0x7824fcada984a4aa731780e8326c1932");
751        assert_eq!(other_hex, "0x9012504833e63da1435c32e96ef8b873");
752        assert_eq!(first, second);
753        assert_ne!(first, other);
754    }
755
756    #[rstest]
757    fn test_cloid_from_client_order_id_has_varied_leading_bytes() {
758        let cloids: Vec<_> = (0..100)
759            .map(|i| {
760                let client_order_id = ClientOrderId::from(format!("O-SAMPLE-{i:03}").as_str());
761                Cloid::from_client_order_id(client_order_id)
762            })
763            .collect();
764
765        let leading_bytes = cloids
766            .iter()
767            .map(|cloid| cloid.0[0])
768            .collect::<AHashSet<_>>();
769
770        let uuid_like = cloids.iter().filter(|cloid| cloid.is_uuid_v4()).count();
771        assert!(uuid_like < cloids.len());
772        assert!(leading_bytes.len() > 1);
773
774        let unique = cloids.iter().collect::<AHashSet<_>>();
775        assert_eq!(unique.len(), cloids.len());
776    }
777
778    #[rstest]
779    fn test_production_like_order_with_deterministic_cloid() {
780        let private_key = EvmPrivateKey::new(
781            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
782        )
783        .unwrap();
784        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
785
786        // Production-like values
787        let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
788        let cloid = Cloid::from_client_order_id(client_order_id);
789
790        println!("=== Production-like Order ===");
791        println!("ClientOrderId: {client_order_id}");
792        println!("Cloid: {}", cloid.to_hex());
793
794        let typed_action = HyperliquidExchangeAction::Order {
795            orders: vec![HyperliquidExchangePlaceOrderRequest {
796                asset: 3, // BTC on testnet
797                is_buy: true,
798                price: dec!(92572.0),
799                size: dec!(0.001),
800                reduce_only: false,
801                kind: HyperliquidExchangeOrderKind::Limit {
802                    limit: HyperliquidExchangeLimitParams {
803                        tif: HyperliquidExchangeTif::Gtc,
804                    },
805                },
806                cloid: Some(cloid),
807            }],
808            grouping: HyperliquidExchangeGrouping::Na,
809            builder: None,
810        };
811
812        // Serialize with msgpack
813        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
814        println!(
815            "MsgPack bytes ({}): {}",
816            action_bytes.len(),
817            hex::encode(&action_bytes)
818        );
819
820        // Decode to verify structure
821        let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
822        println!(
823            "Decoded: {}",
824            serde_json::to_string_pretty(&decoded).unwrap()
825        );
826
827        // Compute connection_id and signing hash
828        let request = SignRequest {
829            action: None,
830            action_bytes: Some(action_bytes),
831            time_nonce: TimeNonce::from_millis(1733833200000), // Dec 10, 2024
832            action_type: HyperliquidActionType::L1,
833            is_testnet: true, // source = "b"
834            vault_address: None,
835            expires_after: None,
836        };
837
838        let connection_id = signer.compute_connection_id(&request).unwrap();
839        println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
840
841        // Create Agent and get signing hash
842        let source = "b".to_string();
843        let agent = Agent {
844            source,
845            connectionId: connection_id,
846        };
847
848        let domain = eip712_domain! {
849            name: "Exchange",
850            version: "1",
851            chain_id: 1337,
852            verifying_contract: Address::ZERO,
853        };
854
855        let signing_hash = agent.eip712_signing_hash(&domain);
856        println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
857
858        // Sign and verify signature format
859        let result = signer.sign(&request).unwrap();
860        let sig_hex = result.signature.to_hex();
861        println!("Signature: {}", sig_hex.expose_secret());
862        assert!(sig_hex.expose_secret().starts_with("0x"));
863        assert_eq!(sig_hex.expose_secret().len(), 132);
864    }
865
866    #[rstest]
867    fn test_price_decimal_formatting() {
868        // Compare how Price::as_decimal() formats vs dec!() macro
869        // Test various price formats
870        let test_cases = [
871            (92572.0_f64, 1_u8, "92572"), // BTC price
872            (92572.5, 1, "92572.5"),      // BTC price with fractional
873            (0.001, 8, "0.001"),          // Small qty
874            (50000.0, 1, "50000"),        // Round number
875            (0.1, 4, "0.1"),              // Typical qty
876        ];
877
878        for (value, precision, expected_normalized) in test_cases {
879            let price = Price::new(value, precision);
880            let price_decimal = price.as_decimal();
881            let normalized = price_decimal.normalize();
882
883            println!(
884                "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
885            );
886
887            assert_eq!(
888                normalized.to_string(),
889                expected_normalized,
890                "Price({value}, {precision}) should normalize to {expected_normalized}"
891            );
892        }
893
894        // Verify dec! macro produces same result
895        let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
896        let price_from_dec = dec!(92572.0).normalize();
897        assert_eq!(
898            price_from_type.to_string(),
899            price_from_dec.to_string(),
900            "Price::as_decimal should match dec! macro"
901        );
902    }
903}