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_primitives::{Address, B256, keccak256};
19use alloy_signer::SignerSync;
20use alloy_signer_local::PrivateKeySigner;
21use alloy_sol_types::{SolStruct, eip712_domain};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25use super::{nonce::TimeNonce, types::HyperliquidActionType};
26use crate::{
27    common::credential::EvmPrivateKey,
28    http::error::{Error, Result},
29};
30
31// Define the Agent struct for L1 signing
32alloy_sol_types::sol! {
33    #[derive(Debug, Serialize, Deserialize)]
34    struct Agent {
35        string source;
36        bytes32 connectionId;
37    }
38}
39
40/// Request to be signed by the Hyperliquid EIP-712 signer.
41#[derive(Debug, Clone)]
42pub struct SignRequest {
43    pub action: Value,                 // For UserSigned actions
44    pub action_bytes: Option<Vec<u8>>, // For L1 actions (pre-serialized MessagePack)
45    pub time_nonce: TimeNonce,
46    pub action_type: HyperliquidActionType,
47    pub is_testnet: bool,
48    pub vault_address: Option<String>,
49}
50
51/// Bundle containing signature for Hyperliquid requests.
52#[derive(Debug, Clone)]
53pub struct SignatureBundle {
54    pub signature: String,
55}
56
57/// EIP-712 signer for Hyperliquid.
58#[derive(Debug, Clone)]
59pub struct HyperliquidEip712Signer {
60    private_key: EvmPrivateKey,
61}
62
63impl HyperliquidEip712Signer {
64    pub fn new(private_key: EvmPrivateKey) -> Self {
65        Self { private_key }
66    }
67
68    pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
69        let signature = match request.action_type {
70            HyperliquidActionType::L1 => self.sign_l1_action(request)?,
71            HyperliquidActionType::UserSigned => {
72                self.sign_user_signed_action(&request.action, request.time_nonce)?
73            }
74        };
75
76        Ok(SignatureBundle { signature })
77    }
78
79    pub fn sign_l1_action(&self, request: &SignRequest) -> Result<String> {
80        // L1 signing for Hyperliquid follows this pattern:
81        // 1. Serialize action with MessagePack (rmp_serde)
82        // 2. Append timestamp + vault info
83        // 3. Hash with keccak256 to get connection_id
84        // 4. Create Agent struct with source + connection_id
85        // 5. Sign Agent with EIP-712
86
87        // Step 1-3: Create connection_id
88        let connection_id = self.compute_connection_id(request)?;
89
90        // Step 4: Create Agent struct
91        let source = if request.is_testnet {
92            "b".to_string()
93        } else {
94            "a".to_string()
95        };
96
97        let agent = Agent {
98            source,
99            connectionId: connection_id,
100        };
101
102        // Step 5: Sign Agent with EIP-712
103        let domain = eip712_domain! {
104            name: "Exchange",
105            version: "1",
106            chain_id: 1337,
107            verifying_contract: Address::ZERO,
108        };
109
110        let signing_hash = agent.eip712_signing_hash(&domain);
111
112        self.sign_hash(&signing_hash.0)
113    }
114
115    fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
116        let mut bytes = if let Some(action_bytes) = &request.action_bytes {
117            action_bytes.clone()
118        } else {
119            log::warn!(
120                "Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
121            );
122            rmp_serde::to_vec_named(&request.action)
123                .map_err(|e| Error::transport(format!("Failed to serialize action: {e}")))?
124        };
125
126        // Append timestamp as big-endian u64
127        let timestamp = request.time_nonce.as_millis() as u64;
128        bytes.extend_from_slice(&timestamp.to_be_bytes());
129
130        if let Some(vault_addr) = &request.vault_address {
131            bytes.push(1); // vault flag
132            let vault_hex = vault_addr.trim_start_matches("0x");
133            let vault_bytes = hex::decode(vault_hex)
134                .map_err(|e| Error::transport(format!("Invalid vault address: {e}")))?;
135            bytes.extend_from_slice(&vault_bytes);
136        } else {
137            bytes.push(0); // no vault
138        }
139
140        Ok(keccak256(&bytes))
141    }
142
143    pub fn sign_user_signed_action(&self, action: &Value, _nonce: TimeNonce) -> Result<String> {
144        let canonicalized = Self::canonicalize_action(action)?;
145
146        // EIP-712 domain separator for Hyperliquid user-signed actions
147        let domain_hash = self.get_domain_hash()?;
148        let action_hash = self.hash_typed_data(&canonicalized)?;
149        let message_hash = self.create_eip712_hash(&domain_hash, &action_hash)?;
150
151        self.sign_hash(&message_hash)
152    }
153
154    fn get_domain_hash(&self) -> Result<[u8; 32]> {
155        // Hyperliquid EIP-712 domain separator
156        // This needs to match Hyperliquid's exact domain configuration
157        let domain_type_hash = keccak256(
158            b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
159        );
160
161        let name_hash = keccak256(b"Hyperliquid");
162        let version_hash = keccak256(b"1");
163
164        // Mainnet chainId = 1, testnet might differ
165        let chain_id: [u8; 32] = {
166            let mut bytes = [0u8; 32];
167            bytes[31] = 1; // chainId = 1 for mainnet
168            bytes
169        };
170
171        // Verifying contract address (needs to be the actual Hyperliquid contract)
172        // This is a placeholder and needs to be replaced with the actual contract address
173        let verifying_contract = hex::decode("0000000000000000000000000000000000000000")
174            .map_err(|e| Error::transport(format!("Failed to decode verifying contract: {e}")))?;
175        let mut contract_bytes = [0u8; 32];
176        contract_bytes[12..].copy_from_slice(&verifying_contract);
177
178        // Hash all components together
179        let mut combined = Vec::with_capacity(160);
180        combined.extend_from_slice(domain_type_hash.as_slice());
181        combined.extend_from_slice(name_hash.as_slice());
182        combined.extend_from_slice(version_hash.as_slice());
183        combined.extend_from_slice(&chain_id);
184        combined.extend_from_slice(&contract_bytes);
185
186        Ok(*keccak256(&combined))
187    }
188
189    fn hash_typed_data(&self, data: &Value) -> Result<[u8; 32]> {
190        // Convert JSON to canonical encoding and hash
191        // This is a simplified version - full implementation needs proper EIP-712 encoding
192        let json_str = serde_json::to_string(data)?;
193        Ok(*keccak256(json_str.as_bytes()))
194    }
195
196    fn create_eip712_hash(
197        &self,
198        domain_hash: &[u8; 32],
199        message_hash: &[u8; 32],
200    ) -> Result<[u8; 32]> {
201        // EIP-712 prefix: "\x19\x01" + domain_separator + message_hash
202        let mut combined = Vec::with_capacity(66);
203        combined.extend_from_slice(b"\x19\x01");
204        combined.extend_from_slice(domain_hash);
205        combined.extend_from_slice(message_hash);
206        Ok(*keccak256(&combined))
207    }
208
209    fn sign_hash(&self, hash: &[u8; 32]) -> Result<String> {
210        // Parse private key and create signer
211        let key_hex = self.private_key.as_hex();
212        let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
213
214        // Create PrivateKeySigner from hex string
215        let signer = PrivateKeySigner::from_str(key_hex)
216            .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
217
218        // Convert [u8; 32] to B256 for signing
219        let hash_b256 = B256::from(*hash);
220
221        // Sign the hash - alloy-signer handles the signing internally
222        let signature = signer
223            .sign_hash_sync(&hash_b256)
224            .map_err(|e| Error::transport(format!("Failed to sign hash: {e}")))?;
225
226        // Extract r, s, v components for Ethereum signature format
227        // Ethereum signature format: 0x + r (64 hex) + s (64 hex) + v (2 hex) = 132 total
228        let r = signature.r();
229        let s = signature.s();
230        let v = signature.v(); // Get the y_parity as bool (true = 1, false = 0)
231
232        // Convert v from bool to Ethereum recovery ID (27 or 28)
233        let v_byte = if v { 28u8 } else { 27u8 };
234
235        // Format as Ethereum signature: 0x + r + s + v (132 hex chars total)
236        Ok(format!("0x{r:064x}{s:064x}{v_byte:02x}"))
237    }
238
239    fn canonicalize_action(action: &Value) -> Result<Value> {
240        match action {
241            Value::Object(obj) => {
242                let mut canonicalized = serde_json::Map::new();
243                for (key, value) in obj {
244                    let canon_value = match key.as_str() {
245                        "destination" | "address" | "user" if value.is_string() => {
246                            Value::String(Self::canonicalize_address(value.as_str().unwrap()))
247                        }
248                        "amount" | "px" | "sz" | "price" | "size" if value.is_string() => {
249                            Value::String(Self::canonicalize_decimal(value.as_str().unwrap()))
250                        }
251                        _ => Self::canonicalize_action(value)?,
252                    };
253                    canonicalized.insert(key.clone(), canon_value);
254                }
255                Ok(Value::Object(canonicalized))
256            }
257            Value::Array(arr) => {
258                let canonicalized: Result<Vec<_>> =
259                    arr.iter().map(Self::canonicalize_action).collect();
260                Ok(Value::Array(canonicalized?))
261            }
262            _ => Ok(action.clone()),
263        }
264    }
265
266    fn canonicalize_address(addr: &str) -> String {
267        if addr.starts_with("0x") || addr.starts_with("0X") {
268            format!("0x{}", &addr[2..].to_lowercase())
269        } else {
270            format!("0x{}", addr.to_lowercase())
271        }
272    }
273
274    fn canonicalize_decimal(decimal: &str) -> String {
275        if let Ok(num) = decimal.parse::<f64>() {
276            if num.fract() == 0.0 {
277                format!("{num:.0}")
278            } else {
279                let trimmed = format!("{num}")
280                    .trim_end_matches('0')
281                    .trim_end_matches('.')
282                    .to_string();
283                if trimmed.is_empty() || trimmed == "-" {
284                    "0".to_string()
285                } else {
286                    trimmed
287                }
288            }
289        } else {
290            decimal.to_string()
291        }
292    }
293
294    pub fn address(&self) -> Result<String> {
295        // Derive Ethereum address from private key using alloy-signer
296        let key_hex = self.private_key.as_hex();
297        let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
298
299        // Create PrivateKeySigner from hex string
300        let signer = PrivateKeySigner::from_str(key_hex)
301            .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
302
303        // Get address from signer and format it properly (not Debug format)
304        let address = format!("{:#x}", signer.address());
305        Ok(address)
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use alloy_sol_types::SolStruct;
312    use rstest::rstest;
313    use serde_json::json;
314
315    use super::*;
316
317    #[rstest]
318    fn test_address_canonicalization() {
319        assert_eq!(
320            HyperliquidEip712Signer::canonicalize_address("0xABCDEF123456789"),
321            "0xabcdef123456789"
322        );
323        assert_eq!(
324            HyperliquidEip712Signer::canonicalize_address("ABCDEF123456789"),
325            "0xabcdef123456789"
326        );
327        assert_eq!(
328            HyperliquidEip712Signer::canonicalize_address("0XABCDEF123456789"),
329            "0xabcdef123456789"
330        );
331    }
332
333    #[rstest]
334    fn test_decimal_canonicalization() {
335        assert_eq!(
336            HyperliquidEip712Signer::canonicalize_decimal("100.000"),
337            "100"
338        );
339        assert_eq!(
340            HyperliquidEip712Signer::canonicalize_decimal("100.100"),
341            "100.1"
342        );
343        assert_eq!(HyperliquidEip712Signer::canonicalize_decimal("0.000"), "0");
344        assert_eq!(
345            HyperliquidEip712Signer::canonicalize_decimal("123.456"),
346            "123.456"
347        );
348        assert_eq!(
349            HyperliquidEip712Signer::canonicalize_decimal("123.450"),
350            "123.45"
351        );
352    }
353
354    #[rstest]
355    fn test_action_canonicalization() {
356        let action = json!({
357            "destination": "0xABCDEF123456789",
358            "amount": "100.000",
359            "other": "unchanged"
360        });
361
362        let canonicalized = HyperliquidEip712Signer::canonicalize_action(&action).unwrap();
363
364        assert_eq!(canonicalized["destination"], "0xabcdef123456789");
365        assert_eq!(canonicalized["amount"], "100");
366        assert_eq!(canonicalized["other"], "unchanged");
367    }
368
369    #[rstest]
370    fn test_sign_request_l1_action() {
371        let private_key = EvmPrivateKey::new(
372            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
373        )
374        .unwrap();
375        let signer = HyperliquidEip712Signer::new(private_key);
376
377        let request = SignRequest {
378            action: json!({
379                "type": "withdraw",
380                "destination": "0xABCDEF123456789",
381                "amount": "100.000"
382            }),
383            action_bytes: None,
384            time_nonce: TimeNonce::from_millis(1640995200000),
385            action_type: HyperliquidActionType::L1,
386            is_testnet: false,
387            vault_address: None,
388        };
389
390        let result = signer.sign(&request).unwrap();
391        // Verify signature format: 0x + 64 hex chars (r) + 64 hex chars (s) + 2 hex chars (v)
392        assert!(result.signature.starts_with("0x"));
393        assert_eq!(result.signature.len(), 132); // 0x + 130 hex chars
394    }
395
396    #[rstest]
397    fn test_sign_request_user_action() {
398        let private_key = EvmPrivateKey::new(
399            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
400        )
401        .unwrap();
402        let signer = HyperliquidEip712Signer::new(private_key);
403
404        let request = SignRequest {
405            action: json!({
406                "type": "order",
407                "coin": "BTC",
408                "px": "50000.00",
409                "sz": "0.1"
410            }),
411            action_bytes: None,
412            time_nonce: TimeNonce::from_millis(1640995200000),
413            action_type: HyperliquidActionType::UserSigned,
414            is_testnet: false,
415            vault_address: None,
416        };
417
418        let result = signer.sign(&request).unwrap();
419        // Verify signature format: 0x + 64 hex chars (r) + 64 hex chars (s) + 2 hex chars (v)
420        assert!(result.signature.starts_with("0x"));
421        assert_eq!(result.signature.len(), 132); // 0x + 130 hex chars
422    }
423
424    #[rstest]
425    fn test_connection_id_matches_python() {
426        use rust_decimal_macros::dec;
427
428        use crate::http::models::{
429            HyperliquidExecAction, HyperliquidExecGrouping, HyperliquidExecLimitParams,
430            HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest, HyperliquidExecTif,
431        };
432
433        // Test that our connection_id computation matches Python SDK exactly.
434        // Python expected output for this test case:
435        // MsgPack bytes: 83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61
436        // Connection ID: 207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40
437
438        let private_key = EvmPrivateKey::new(
439            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
440        )
441        .unwrap();
442        let signer = HyperliquidEip712Signer::new(private_key);
443
444        // NOTE: json! macro sorts keys alphabetically, but Python preserves insertion order.
445        // Field order: Python uses "type", "orders", "grouping"
446        // json! produces: "grouping", "orders", "type" (alphabetical)
447        // This causes hash mismatch!
448        //
449        // When using typed structs (HyperliquidExecAction), serde follows declaration order.
450        // Let's test with the typed struct approach.
451
452        let typed_action = HyperliquidExecAction::Order {
453            orders: vec![HyperliquidExecPlaceOrderRequest {
454                asset: 0,
455                is_buy: true,
456                price: dec!(50000),
457                size: dec!(0.1),
458                reduce_only: false,
459                kind: HyperliquidExecOrderKind::Limit {
460                    limit: HyperliquidExecLimitParams {
461                        tif: HyperliquidExecTif::Gtc,
462                    },
463                },
464                cloid: None,
465            }],
466            grouping: HyperliquidExecGrouping::Na,
467            builder: None,
468        };
469
470        // Serialize the typed struct with msgpack
471        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
472        println!(
473            "Rust typed MsgPack bytes ({}): {}",
474            action_bytes.len(),
475            hex::encode(&action_bytes)
476        );
477
478        // Expected from Python
479        let python_msgpack = hex::decode(
480            "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
481        )
482        .unwrap();
483        println!(
484            "Python MsgPack bytes ({}): {}",
485            python_msgpack.len(),
486            hex::encode(&python_msgpack)
487        );
488
489        // Compare msgpack bytes
490        assert_eq!(
491            hex::encode(&action_bytes),
492            hex::encode(&python_msgpack),
493            "MsgPack bytes should match Python"
494        );
495
496        // Now test the full connection_id computation
497        let action_value = serde_json::to_value(&typed_action).unwrap();
498        let request = SignRequest {
499            action: action_value,
500            action_bytes: Some(action_bytes),
501            time_nonce: TimeNonce::from_millis(1640995200000),
502            action_type: HyperliquidActionType::L1,
503            is_testnet: true, // source = "b"
504            vault_address: None,
505        };
506
507        let connection_id = signer.compute_connection_id(&request).unwrap();
508        println!(
509            "Rust Connection ID: {}",
510            hex::encode(connection_id.as_slice())
511        );
512
513        // Expected from Python
514        let expected_connection_id =
515            "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
516        assert_eq!(
517            hex::encode(connection_id.as_slice()),
518            expected_connection_id,
519            "Connection ID should match Python"
520        );
521
522        // Now test the full signing hash
523        // Python expected values:
524        // Domain separator: d79297fcdf2ffcd4ae223d01edaa2ba214ff8f401d7c9300d995d17c82aa4040
525        // Struct hash: 99c7d776d74816c42973fbe58bb0f0d03c80324bef180220196d0dccf01672c5
526        // Signing hash: 5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d
527
528        // Create Agent and sign - matching our sign_l1_action logic
529        let source = "b".to_string(); // is_testnet = true
530        let agent = Agent {
531            source,
532            connectionId: connection_id,
533        };
534
535        let domain = eip712_domain! {
536            name: "Exchange",
537            version: "1",
538            chain_id: 1337,
539            verifying_contract: Address::ZERO,
540        };
541
542        let signing_hash = agent.eip712_signing_hash(&domain);
543        println!(
544            "Rust EIP-712 signing hash: {}",
545            hex::encode(signing_hash.as_slice())
546        );
547
548        // Expected from Python
549        let expected_signing_hash =
550            "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
551        assert_eq!(
552            hex::encode(signing_hash.as_slice()),
553            expected_signing_hash,
554            "EIP-712 signing hash should match Python"
555        );
556    }
557
558    #[rstest]
559    fn test_connection_id_matches_python_with_builder_fee() {
560        use rust_decimal_macros::dec;
561
562        use crate::http::models::{
563            HyperliquidExecAction, HyperliquidExecBuilderFee, HyperliquidExecGrouping,
564            HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest,
565            HyperliquidExecTif,
566        };
567
568        // Test with builder fee included (what production uses).
569        // Python expected output:
570        // MsgPack bytes (132): 84a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61a76275696c64657282a162d92a307839623665326665343132346564336537613662346638356537383630653033323232326234333136a16601
571        // Connection ID: 235d93388ffa044d5fb14a7fe8103a8a29b73d1e2049cd086e7903671a6cfb49
572        // Signing hash: 6f046f4b02e79610b8cf26c73505f8de3ff1d91d6953c5e972fbf198a5311a41
573
574        let private_key = EvmPrivateKey::new(
575            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
576        )
577        .unwrap();
578        let signer = HyperliquidEip712Signer::new(private_key);
579
580        let typed_action = HyperliquidExecAction::Order {
581            orders: vec![HyperliquidExecPlaceOrderRequest {
582                asset: 0,
583                is_buy: true,
584                price: dec!(50000),
585                size: dec!(0.1),
586                reduce_only: false,
587                kind: HyperliquidExecOrderKind::Limit {
588                    limit: HyperliquidExecLimitParams {
589                        tif: HyperliquidExecTif::Gtc,
590                    },
591                },
592                cloid: None,
593            }],
594            grouping: HyperliquidExecGrouping::Na,
595            builder: Some(HyperliquidExecBuilderFee {
596                address: "0x9b6e2fe4124ed3e7a6b4f85e7860e032222b4316".to_string(),
597                fee_tenths_bp: 1,
598            }),
599        };
600
601        // Serialize the typed struct with msgpack
602        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
603        println!(
604            "Rust typed MsgPack bytes with builder ({}): {}",
605            action_bytes.len(),
606            hex::encode(&action_bytes)
607        );
608
609        // Expected from Python
610        let python_msgpack = hex::decode(
611            "84a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61a76275696c64657282a162d92a307839623665326665343132346564336537613662346638356537383630653033323232326234333136a16601",
612        )
613        .unwrap();
614        println!(
615            "Python MsgPack bytes with builder ({}): {}",
616            python_msgpack.len(),
617            hex::encode(&python_msgpack)
618        );
619
620        // Compare msgpack bytes
621        assert_eq!(
622            hex::encode(&action_bytes),
623            hex::encode(&python_msgpack),
624            "MsgPack bytes with builder should match Python"
625        );
626
627        // Test connection_id
628        let action_value = serde_json::to_value(&typed_action).unwrap();
629        let request = SignRequest {
630            action: action_value,
631            action_bytes: Some(action_bytes),
632            time_nonce: TimeNonce::from_millis(1640995200000),
633            action_type: HyperliquidActionType::L1,
634            is_testnet: true,
635            vault_address: None,
636        };
637
638        let connection_id = signer.compute_connection_id(&request).unwrap();
639        println!(
640            "Rust Connection ID with builder: {}",
641            hex::encode(connection_id.as_slice())
642        );
643
644        let expected_connection_id =
645            "235d93388ffa044d5fb14a7fe8103a8a29b73d1e2049cd086e7903671a6cfb49";
646        assert_eq!(
647            hex::encode(connection_id.as_slice()),
648            expected_connection_id,
649            "Connection ID with builder should match Python"
650        );
651
652        // Test signing hash
653        let source = "b".to_string();
654        let agent = Agent {
655            source,
656            connectionId: connection_id,
657        };
658
659        let domain = eip712_domain! {
660            name: "Exchange",
661            version: "1",
662            chain_id: 1337,
663            verifying_contract: Address::ZERO,
664        };
665
666        let signing_hash = agent.eip712_signing_hash(&domain);
667        println!(
668            "Rust EIP-712 signing hash with builder: {}",
669            hex::encode(signing_hash.as_slice())
670        );
671
672        let expected_signing_hash =
673            "6f046f4b02e79610b8cf26c73505f8de3ff1d91d6953c5e972fbf198a5311a41";
674        assert_eq!(
675            hex::encode(signing_hash.as_slice()),
676            expected_signing_hash,
677            "EIP-712 signing hash with builder should match Python"
678        );
679    }
680
681    #[rstest]
682    fn test_connection_id_with_cloid() {
683        use rust_decimal_macros::dec;
684
685        use crate::http::models::{
686            Cloid, HyperliquidExecAction, HyperliquidExecBuilderFee, HyperliquidExecGrouping,
687            HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest,
688            HyperliquidExecTif,
689        };
690
691        // Test with CLOID included - this is what production actually sends.
692        // The key difference: production always includes a cloid field.
693
694        let private_key = EvmPrivateKey::new(
695            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
696        )
697        .unwrap();
698        let _signer = HyperliquidEip712Signer::new(private_key);
699
700        // Create a cloid - this is how Python SDK expects it
701        let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
702        println!("Cloid hex: {}", cloid.to_hex());
703
704        let typed_action = HyperliquidExecAction::Order {
705            orders: vec![HyperliquidExecPlaceOrderRequest {
706                asset: 0,
707                is_buy: true,
708                price: dec!(50000),
709                size: dec!(0.1),
710                reduce_only: false,
711                kind: HyperliquidExecOrderKind::Limit {
712                    limit: HyperliquidExecLimitParams {
713                        tif: HyperliquidExecTif::Gtc,
714                    },
715                },
716                cloid: Some(cloid),
717            }],
718            grouping: HyperliquidExecGrouping::Na,
719            builder: Some(HyperliquidExecBuilderFee {
720                address: "0x9b6e2fe4124ed3e7a6b4f85e7860e032222b4316".to_string(),
721                fee_tenths_bp: 1,
722            }),
723        };
724
725        // Serialize the typed struct with msgpack
726        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
727        println!(
728            "Rust MsgPack bytes with cloid ({}): {}",
729            action_bytes.len(),
730            hex::encode(&action_bytes)
731        );
732
733        // Decode to see the structure
734        let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
735        println!(
736            "Decoded structure: {}",
737            serde_json::to_string_pretty(&decoded).unwrap()
738        );
739
740        // Verify the cloid is in the right place
741        let orders = decoded.get("orders").unwrap().as_array().unwrap();
742        let first_order = &orders[0];
743        let cloid_field = first_order.get("c").unwrap();
744        println!("Cloid in msgpack: {cloid_field}");
745        assert_eq!(
746            cloid_field.as_str().unwrap(),
747            "0x1234567890abcdef1234567890abcdef"
748        );
749
750        // Verify order field order is correct: a, b, p, s, r, t, c
751        let order_json = serde_json::to_string(first_order).unwrap();
752        println!("Order JSON: {order_json}");
753    }
754
755    #[rstest]
756    fn test_cloid_from_client_order_id() {
757        use nautilus_model::identifiers::ClientOrderId;
758
759        use crate::http::models::Cloid;
760
761        // Test that Cloid::from_client_order_id produces valid hex format
762        // This is how production creates cloids
763        let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
764        let cloid = Cloid::from_client_order_id(client_order_id);
765
766        println!("ClientOrderId: {client_order_id}");
767        println!("Cloid hex: {}", cloid.to_hex());
768
769        // Verify format: 0x + 32 hex chars
770        let hex = cloid.to_hex();
771        assert!(hex.starts_with("0x"), "Should start with 0x");
772        assert_eq!(hex.len(), 34, "Should be 34 chars (0x + 32 hex)");
773
774        // Verify all chars after 0x are valid hex
775        for c in hex[2..].chars() {
776            assert!(c.is_ascii_hexdigit(), "Should be hex digit: {c}");
777        }
778
779        // Verify serialization to JSON
780        let json = serde_json::to_string(&cloid).unwrap();
781        println!("Cloid JSON: {json}");
782        assert!(json.contains(&hex));
783    }
784
785    #[rstest]
786    fn test_production_like_order_with_hashed_cloid() {
787        use nautilus_model::identifiers::ClientOrderId;
788        use rust_decimal_macros::dec;
789
790        use crate::http::models::{
791            Cloid, HyperliquidExecAction, HyperliquidExecBuilderFee, HyperliquidExecGrouping,
792            HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest,
793            HyperliquidExecTif,
794        };
795
796        // Full production-like test with cloid from ClientOrderId
797
798        let private_key = EvmPrivateKey::new(
799            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
800        )
801        .unwrap();
802        let signer = HyperliquidEip712Signer::new(private_key);
803
804        // Production-like values
805        let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
806        let cloid = Cloid::from_client_order_id(client_order_id);
807
808        println!("=== Production-like Order ===");
809        println!("ClientOrderId: {client_order_id}");
810        println!("Cloid: {}", cloid.to_hex());
811
812        let typed_action = HyperliquidExecAction::Order {
813            orders: vec![HyperliquidExecPlaceOrderRequest {
814                asset: 3, // BTC on testnet
815                is_buy: true,
816                price: dec!(92572.0),
817                size: dec!(0.001),
818                reduce_only: false,
819                kind: HyperliquidExecOrderKind::Limit {
820                    limit: HyperliquidExecLimitParams {
821                        tif: HyperliquidExecTif::Gtc,
822                    },
823                },
824                cloid: Some(cloid),
825            }],
826            grouping: HyperliquidExecGrouping::Na,
827            builder: Some(HyperliquidExecBuilderFee {
828                address: "0x9b6e2fe4124ed3e7a6b4f85e7860e032222b4316".to_string(),
829                fee_tenths_bp: 1,
830            }),
831        };
832
833        // Serialize with msgpack
834        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
835        println!(
836            "MsgPack bytes ({}): {}",
837            action_bytes.len(),
838            hex::encode(&action_bytes)
839        );
840
841        // Decode to verify structure
842        let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
843        println!(
844            "Decoded: {}",
845            serde_json::to_string_pretty(&decoded).unwrap()
846        );
847
848        // Compute connection_id and signing hash
849        let action_value = serde_json::to_value(&typed_action).unwrap();
850        let request = SignRequest {
851            action: action_value,
852            action_bytes: Some(action_bytes),
853            time_nonce: TimeNonce::from_millis(1733833200000), // Dec 10, 2024
854            action_type: HyperliquidActionType::L1,
855            is_testnet: true, // source = "b"
856            vault_address: None,
857        };
858
859        let connection_id = signer.compute_connection_id(&request).unwrap();
860        println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
861
862        // Create Agent and get signing hash
863        let source = "b".to_string();
864        let agent = Agent {
865            source,
866            connectionId: connection_id,
867        };
868
869        let domain = eip712_domain! {
870            name: "Exchange",
871            version: "1",
872            chain_id: 1337,
873            verifying_contract: Address::ZERO,
874        };
875
876        let signing_hash = agent.eip712_signing_hash(&domain);
877        println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
878
879        // Sign and verify signature format
880        let result = signer.sign(&request).unwrap();
881        println!("Signature: {}", result.signature);
882        assert!(result.signature.starts_with("0x"));
883        assert_eq!(result.signature.len(), 132);
884    }
885
886    #[rstest]
887    fn test_price_decimal_formatting() {
888        use nautilus_model::types::Price;
889        use rust_decimal_macros::dec;
890
891        // Compare how Price::as_decimal() formats vs dec!() macro
892        // Test various price formats
893        let test_cases = [
894            (92572.0_f64, 1_u8, "92572"), // BTC price
895            (92572.5, 1, "92572.5"),      // BTC price with fractional
896            (0.001, 8, "0.001"),          // Small qty
897            (50000.0, 1, "50000"),        // Round number
898            (0.1, 4, "0.1"),              // Typical qty
899        ];
900
901        for (value, precision, expected_normalized) in test_cases {
902            let price = Price::new(value, precision);
903            let price_decimal = price.as_decimal();
904            let normalized = price_decimal.normalize();
905
906            println!(
907                "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
908            );
909
910            assert_eq!(
911                normalized.to_string(),
912                expected_normalized,
913                "Price({value}, {precision}) should normalize to {expected_normalized}"
914            );
915        }
916
917        // Verify dec! macro produces same result
918        let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
919        let price_from_dec = dec!(92572.0).normalize();
920        assert_eq!(
921            price_from_type.to_string(),
922            price_from_dec.to_string(),
923            "Price::as_decimal should match dec! macro"
924        );
925    }
926}