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 nautilus_core::hex;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27use super::{nonce::TimeNonce, types::HyperliquidActionType};
28use crate::{
29    common::credential::EvmPrivateKey,
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#[derive(Debug, Clone)]
47pub struct SignRequest {
48    pub action: Value,                 // For UserSigned actions
49    pub action_bytes: Option<Vec<u8>>, // For L1 actions (pre-serialized MessagePack)
50    pub time_nonce: TimeNonce,
51    pub action_type: HyperliquidActionType,
52    pub is_testnet: bool,
53    pub vault_address: Option<String>,
54}
55
56/// Bundle containing signature for Hyperliquid requests.
57#[derive(Debug, Clone)]
58pub struct SignatureBundle {
59    pub signature: HyperliquidSignature,
60}
61
62/// EIP-712 signer for Hyperliquid.
63#[derive(Debug, Clone)]
64pub struct HyperliquidEip712Signer {
65    signer: PrivateKeySigner,
66    address: String,
67    domain: Eip712Domain,
68}
69
70impl HyperliquidEip712Signer {
71    /// Creates a new [`HyperliquidEip712Signer`].
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if the private key cannot be parsed.
76    pub fn new(private_key: &EvmPrivateKey) -> Result<Self> {
77        let key_hex = private_key.as_hex();
78        let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
79
80        let signer = PrivateKeySigner::from_str(key_hex)
81            .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
82
83        let address = format!("{:#x}", signer.address());
84
85        let domain = eip712_domain! {
86            name: "Exchange",
87            version: "1",
88            chain_id: 1337,
89            verifying_contract: Address::ZERO,
90        };
91
92        Ok(Self {
93            signer,
94            address,
95            domain,
96        })
97    }
98
99    pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
100        let signature = match request.action_type {
101            HyperliquidActionType::L1 => self.sign_l1_action(request)?,
102            HyperliquidActionType::UserSigned => {
103                return Err(Error::transport(
104                    "UserSigned signing is not implemented; all exchange actions use L1",
105                ));
106            }
107        };
108
109        Ok(SignatureBundle { signature })
110    }
111
112    pub fn sign_l1_action(&self, request: &SignRequest) -> Result<HyperliquidSignature> {
113        // L1 signing for Hyperliquid follows this pattern:
114        // 1. Serialize action with MessagePack (rmp_serde)
115        // 2. Append timestamp + vault info
116        // 3. Hash with keccak256 to get connection_id
117        // 4. Create Agent struct with source + connection_id
118        // 5. Sign Agent with EIP-712
119
120        // Step 1-3: Create connection_id
121        let connection_id = self.compute_connection_id(request)?;
122
123        // Step 4: Create Agent struct
124        let source = if request.is_testnet { "b" } else { "a" };
125
126        let agent = Agent {
127            source: source.to_string(),
128            connectionId: connection_id,
129        };
130
131        // Step 5: Sign Agent with EIP-712
132        let signing_hash = agent.eip712_signing_hash(&self.domain);
133
134        self.sign_hash(&signing_hash.0)
135    }
136
137    fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
138        let mut bytes = if let Some(action_bytes) = &request.action_bytes {
139            action_bytes.clone()
140        } else {
141            log::warn!(
142                "Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
143            );
144            rmp_serde::to_vec_named(&request.action)
145                .map_err(|e| Error::transport(format!("Failed to serialize action: {e}")))?
146        };
147
148        // Append timestamp as big-endian u64
149        let timestamp = request.time_nonce.as_millis() as u64;
150        bytes.extend_from_slice(&timestamp.to_be_bytes());
151
152        if let Some(vault_addr) = &request.vault_address {
153            bytes.push(1); // vault flag
154            let vault_hex = vault_addr.trim_start_matches("0x");
155            let vault_bytes = hex::decode(vault_hex)
156                .map_err(|e| Error::transport(format!("Invalid vault address: {e}")))?;
157            bytes.extend_from_slice(&vault_bytes);
158        } else {
159            bytes.push(0); // no vault
160        }
161
162        Ok(keccak256(&bytes))
163    }
164
165    fn sign_hash(&self, hash: &[u8; 32]) -> Result<HyperliquidSignature> {
166        let hash_b256 = B256::from(*hash);
167
168        let signature = self
169            .signer
170            .sign_hash_sync(&hash_b256)
171            .map_err(|e| Error::transport(format!("Failed to sign hash: {e}")))?;
172
173        let r = signature.r();
174        let s = signature.s();
175        let v = signature.v();
176        let v_byte = if v { 28u8 } else { 27u8 };
177
178        Ok(HyperliquidSignature::new(
179            format!("0x{r:064x}"),
180            format!("0x{s:064x}"),
181            v_byte as u64,
182        ))
183    }
184
185    /// Returns the signer's Ethereum address.
186    pub fn address(&self) -> Result<String> {
187        Ok(self.address.clone())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use alloy::sol_types::SolStruct;
194    use nautilus_model::{identifiers::ClientOrderId, types::Price};
195    use rstest::rstest;
196    use rust_decimal_macros::dec;
197    use serde_json::json;
198
199    use super::*;
200    use crate::http::models::{
201        Cloid, HyperliquidExecAction, HyperliquidExecGrouping, HyperliquidExecLimitParams,
202        HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest, HyperliquidExecTif,
203    };
204
205    #[rstest]
206    fn test_sign_request_l1_action() {
207        let private_key = EvmPrivateKey::new(
208            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
209        )
210        .unwrap();
211        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
212
213        let request = SignRequest {
214            action: json!({
215                "type": "withdraw",
216                "destination": "0xABCDEF123456789",
217                "amount": "100.000"
218            }),
219            action_bytes: None,
220            time_nonce: TimeNonce::from_millis(1640995200000),
221            action_type: HyperliquidActionType::L1,
222            is_testnet: false,
223            vault_address: None,
224        };
225
226        let result = signer.sign(&request).unwrap();
227        let sig_hex = result.signature.to_hex();
228        // Verify signature format: 0x + 64 hex chars (r) + 64 hex chars (s) + 2 hex chars (v)
229        assert!(sig_hex.starts_with("0x"));
230        assert_eq!(sig_hex.len(), 132); // 0x + 130 hex chars
231    }
232
233    #[rstest]
234    fn test_sign_user_signed_returns_error() {
235        let private_key = EvmPrivateKey::new(
236            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
237        )
238        .unwrap();
239        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
240
241        let request = SignRequest {
242            action: json!({"type": "order"}),
243            action_bytes: None,
244            time_nonce: TimeNonce::from_millis(1640995200000),
245            action_type: HyperliquidActionType::UserSigned,
246            is_testnet: false,
247            vault_address: None,
248        };
249
250        assert!(signer.sign(&request).is_err());
251    }
252
253    #[rstest]
254    fn test_connection_id_matches_python() {
255        // Test that our connection_id computation matches Python SDK exactly.
256        // Python expected output for this test case:
257        // MsgPack bytes: 83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61
258        // Connection ID: 207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40
259
260        let private_key = EvmPrivateKey::new(
261            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
262        )
263        .unwrap();
264        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
265
266        // NOTE: json! macro sorts keys alphabetically, but Python preserves insertion order.
267        // Field order: Python uses "type", "orders", "grouping"
268        // json! produces: "grouping", "orders", "type" (alphabetical)
269        // This causes hash mismatch!
270        //
271        // When using typed structs (HyperliquidExecAction), serde follows declaration order.
272        // Let's test with the typed struct approach.
273
274        let typed_action = HyperliquidExecAction::Order {
275            orders: vec![HyperliquidExecPlaceOrderRequest {
276                asset: 0,
277                is_buy: true,
278                price: dec!(50000),
279                size: dec!(0.1),
280                reduce_only: false,
281                kind: HyperliquidExecOrderKind::Limit {
282                    limit: HyperliquidExecLimitParams {
283                        tif: HyperliquidExecTif::Gtc,
284                    },
285                },
286                cloid: None,
287            }],
288            grouping: HyperliquidExecGrouping::Na,
289            builder: None,
290        };
291
292        // Serialize the typed struct with msgpack
293        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
294        println!(
295            "Rust typed MsgPack bytes ({}): {}",
296            action_bytes.len(),
297            hex::encode(&action_bytes)
298        );
299
300        // Expected from Python
301        let python_msgpack = hex::decode(
302            "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
303        )
304        .unwrap();
305        println!(
306            "Python MsgPack bytes ({}): {}",
307            python_msgpack.len(),
308            hex::encode(&python_msgpack)
309        );
310
311        // Compare msgpack bytes
312        assert_eq!(
313            hex::encode(&action_bytes),
314            hex::encode(&python_msgpack),
315            "MsgPack bytes should match Python"
316        );
317
318        // Now test the full connection_id computation
319        let action_value = serde_json::to_value(&typed_action).unwrap();
320        let request = SignRequest {
321            action: action_value,
322            action_bytes: Some(action_bytes),
323            time_nonce: TimeNonce::from_millis(1640995200000),
324            action_type: HyperliquidActionType::L1,
325            is_testnet: true, // source = "b"
326            vault_address: None,
327        };
328
329        let connection_id = signer.compute_connection_id(&request).unwrap();
330        println!(
331            "Rust Connection ID: {}",
332            hex::encode(connection_id.as_slice())
333        );
334
335        // Expected from Python
336        let expected_connection_id =
337            "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
338        assert_eq!(
339            hex::encode(connection_id.as_slice()),
340            expected_connection_id,
341            "Connection ID should match Python"
342        );
343
344        // Now test the full signing hash
345        // Python expected values:
346        // Domain separator: d79297fcdf2ffcd4ae223d01edaa2ba214ff8f401d7c9300d995d17c82aa4040
347        // Struct hash: 99c7d776d74816c42973fbe58bb0f0d03c80324bef180220196d0dccf01672c5
348        // Signing hash: 5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d
349
350        // Create Agent and sign - matching our sign_l1_action logic
351        let source = "b".to_string(); // is_testnet = true
352        let agent = Agent {
353            source,
354            connectionId: connection_id,
355        };
356
357        let domain = eip712_domain! {
358            name: "Exchange",
359            version: "1",
360            chain_id: 1337,
361            verifying_contract: Address::ZERO,
362        };
363
364        let signing_hash = agent.eip712_signing_hash(&domain);
365        println!(
366            "Rust EIP-712 signing hash: {}",
367            hex::encode(signing_hash.as_slice())
368        );
369
370        // Expected from Python
371        let expected_signing_hash =
372            "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
373        assert_eq!(
374            hex::encode(signing_hash.as_slice()),
375            expected_signing_hash,
376            "EIP-712 signing hash should match Python"
377        );
378    }
379
380    #[rstest]
381    fn test_connection_id_with_cloid() {
382        // Test with CLOID included - this is what production actually sends.
383        // The key difference: production always includes a cloid field.
384
385        let private_key = EvmPrivateKey::new(
386            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
387        )
388        .unwrap();
389        let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
390
391        // Create a cloid - this is how Python SDK expects it
392        let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
393        println!("Cloid hex: {}", cloid.to_hex());
394
395        let typed_action = HyperliquidExecAction::Order {
396            orders: vec![HyperliquidExecPlaceOrderRequest {
397                asset: 0,
398                is_buy: true,
399                price: dec!(50000),
400                size: dec!(0.1),
401                reduce_only: false,
402                kind: HyperliquidExecOrderKind::Limit {
403                    limit: HyperliquidExecLimitParams {
404                        tif: HyperliquidExecTif::Gtc,
405                    },
406                },
407                cloid: Some(cloid),
408            }],
409            grouping: HyperliquidExecGrouping::Na,
410            builder: None,
411        };
412
413        // Serialize the typed struct with msgpack
414        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
415        println!(
416            "Rust MsgPack bytes with cloid ({}): {}",
417            action_bytes.len(),
418            hex::encode(&action_bytes)
419        );
420
421        // Decode to see the structure
422        let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
423        println!(
424            "Decoded structure: {}",
425            serde_json::to_string_pretty(&decoded).unwrap()
426        );
427
428        // Verify the cloid is in the right place
429        let orders = decoded.get("orders").unwrap().as_array().unwrap();
430        let first_order = &orders[0];
431        let cloid_field = first_order.get("c").unwrap();
432        println!("Cloid in msgpack: {cloid_field}");
433        assert_eq!(
434            cloid_field.as_str().unwrap(),
435            "0x1234567890abcdef1234567890abcdef"
436        );
437
438        // Verify order field order is correct: a, b, p, s, r, t, c
439        let order_json = serde_json::to_string(first_order).unwrap();
440        println!("Order JSON: {order_json}");
441    }
442
443    #[rstest]
444    fn test_cloid_from_client_order_id() {
445        // Test that Cloid::from_client_order_id produces valid hex format
446        // This is how production creates cloids
447        let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
448        let cloid = Cloid::from_client_order_id(client_order_id);
449
450        println!("ClientOrderId: {client_order_id}");
451        println!("Cloid hex: {}", cloid.to_hex());
452
453        // Verify format: 0x + 32 hex chars
454        let hex = cloid.to_hex();
455        assert!(hex.starts_with("0x"), "Should start with 0x");
456        assert_eq!(hex.len(), 34, "Should be 34 chars (0x + 32 hex)");
457
458        // Verify all chars after 0x are valid hex
459        for c in hex[2..].chars() {
460            assert!(c.is_ascii_hexdigit(), "Should be hex digit: {c}");
461        }
462
463        // Verify serialization to JSON
464        let json = serde_json::to_string(&cloid).unwrap();
465        println!("Cloid JSON: {json}");
466        assert!(json.contains(&hex));
467    }
468
469    #[rstest]
470    fn test_production_like_order_with_hashed_cloid() {
471        // Full production-like test with cloid from ClientOrderId
472
473        let private_key = EvmPrivateKey::new(
474            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
475        )
476        .unwrap();
477        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
478
479        // Production-like values
480        let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
481        let cloid = Cloid::from_client_order_id(client_order_id);
482
483        println!("=== Production-like Order ===");
484        println!("ClientOrderId: {client_order_id}");
485        println!("Cloid: {}", cloid.to_hex());
486
487        let typed_action = HyperliquidExecAction::Order {
488            orders: vec![HyperliquidExecPlaceOrderRequest {
489                asset: 3, // BTC on testnet
490                is_buy: true,
491                price: dec!(92572.0),
492                size: dec!(0.001),
493                reduce_only: false,
494                kind: HyperliquidExecOrderKind::Limit {
495                    limit: HyperliquidExecLimitParams {
496                        tif: HyperliquidExecTif::Gtc,
497                    },
498                },
499                cloid: Some(cloid),
500            }],
501            grouping: HyperliquidExecGrouping::Na,
502            builder: None,
503        };
504
505        // Serialize with msgpack
506        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
507        println!(
508            "MsgPack bytes ({}): {}",
509            action_bytes.len(),
510            hex::encode(&action_bytes)
511        );
512
513        // Decode to verify structure
514        let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
515        println!(
516            "Decoded: {}",
517            serde_json::to_string_pretty(&decoded).unwrap()
518        );
519
520        // Compute connection_id and signing hash
521        let action_value = serde_json::to_value(&typed_action).unwrap();
522        let request = SignRequest {
523            action: action_value,
524            action_bytes: Some(action_bytes),
525            time_nonce: TimeNonce::from_millis(1733833200000), // Dec 10, 2024
526            action_type: HyperliquidActionType::L1,
527            is_testnet: true, // source = "b"
528            vault_address: None,
529        };
530
531        let connection_id = signer.compute_connection_id(&request).unwrap();
532        println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
533
534        // Create Agent and get signing hash
535        let source = "b".to_string();
536        let agent = Agent {
537            source,
538            connectionId: connection_id,
539        };
540
541        let domain = eip712_domain! {
542            name: "Exchange",
543            version: "1",
544            chain_id: 1337,
545            verifying_contract: Address::ZERO,
546        };
547
548        let signing_hash = agent.eip712_signing_hash(&domain);
549        println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
550
551        // Sign and verify signature format
552        let result = signer.sign(&request).unwrap();
553        let sig_hex = result.signature.to_hex();
554        println!("Signature: {sig_hex}");
555        assert!(sig_hex.starts_with("0x"));
556        assert_eq!(sig_hex.len(), 132);
557    }
558
559    #[rstest]
560    fn test_price_decimal_formatting() {
561        // Compare how Price::as_decimal() formats vs dec!() macro
562        // Test various price formats
563        let test_cases = [
564            (92572.0_f64, 1_u8, "92572"), // BTC price
565            (92572.5, 1, "92572.5"),      // BTC price with fractional
566            (0.001, 8, "0.001"),          // Small qty
567            (50000.0, 1, "50000"),        // Round number
568            (0.1, 4, "0.1"),              // Typical qty
569        ];
570
571        for (value, precision, expected_normalized) in test_cases {
572            let price = Price::new(value, precision);
573            let price_decimal = price.as_decimal();
574            let normalized = price_decimal.normalize();
575
576            println!(
577                "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
578            );
579
580            assert_eq!(
581                normalized.to_string(),
582                expected_normalized,
583                "Price({value}, {precision}) should normalize to {expected_normalized}"
584            );
585        }
586
587        // Verify dec! macro produces same result
588        let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
589        let price_from_dec = dec!(92572.0).normalize();
590        assert_eq!(
591            price_from_type.to_string(),
592            price_from_dec.to_string(),
593            "Price::as_decimal should match dec! macro"
594        );
595    }
596}