nautilus_hyperliquid/signing/
signers.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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 using alloy_sol_types
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        // Sign the hash
113        self.sign_hash(&signing_hash.0)
114    }
115
116    fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
117        // Use pre-serialized MessagePack bytes if provided, otherwise serialize the JSON action
118        let mut bytes = if let Some(action_bytes) = &request.action_bytes {
119            action_bytes.clone()
120        } else {
121            // Fallback: serialize JSON Value with MessagePack
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        // Append vault address if present
131        if let Some(vault_addr) = &request.vault_address {
132            bytes.push(1); // vault flag
133            // Parse vault address and append bytes
134            let vault_hex = vault_addr.trim_start_matches("0x");
135            let vault_bytes = hex::decode(vault_hex)
136                .map_err(|e| Error::transport(format!("Invalid vault address: {e}")))?;
137            bytes.extend_from_slice(&vault_bytes);
138        } else {
139            bytes.push(0); // no vault
140        }
141
142        // Hash with keccak256
143        Ok(keccak256(&bytes))
144    }
145
146    pub fn sign_user_signed_action(&self, action: &Value, _nonce: TimeNonce) -> Result<String> {
147        let canonicalized = Self::canonicalize_action(action)?;
148
149        // EIP-712 domain separator for Hyperliquid user-signed actions
150        let domain_hash = self.get_domain_hash()?;
151
152        // Create the structured data hash
153        let action_hash = self.hash_typed_data(&canonicalized)?;
154
155        // Combine with EIP-712 prefix
156        let message_hash = self.create_eip712_hash(&domain_hash, &action_hash)?;
157
158        // Sign with private key
159        self.sign_hash(&message_hash)
160    }
161
162    fn get_domain_hash(&self) -> Result<[u8; 32]> {
163        // Hyperliquid EIP-712 domain separator
164        // This needs to match Hyperliquid's exact domain configuration
165        let domain_type_hash = keccak256(
166            b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
167        );
168
169        let name_hash = keccak256(b"Hyperliquid");
170        let version_hash = keccak256(b"1");
171
172        // Mainnet chainId = 1, testnet might differ
173        let chain_id: [u8; 32] = {
174            let mut bytes = [0u8; 32];
175            bytes[31] = 1; // chainId = 1 for mainnet
176            bytes
177        };
178
179        // Verifying contract address (needs to be the actual Hyperliquid contract)
180        // This is a placeholder and needs to be replaced with the actual contract address
181        let verifying_contract = hex::decode("0000000000000000000000000000000000000000")
182            .map_err(|e| Error::transport(format!("Failed to decode verifying contract: {e}")))?;
183        let mut contract_bytes = [0u8; 32];
184        contract_bytes[12..].copy_from_slice(&verifying_contract);
185
186        // Hash all components together
187        let mut combined = Vec::with_capacity(160);
188        combined.extend_from_slice(domain_type_hash.as_slice());
189        combined.extend_from_slice(name_hash.as_slice());
190        combined.extend_from_slice(version_hash.as_slice());
191        combined.extend_from_slice(&chain_id);
192        combined.extend_from_slice(&contract_bytes);
193
194        Ok(*keccak256(&combined))
195    }
196
197    fn hash_typed_data(&self, data: &Value) -> Result<[u8; 32]> {
198        // Convert JSON to canonical encoding and hash
199        // This is a simplified version - full implementation needs proper EIP-712 encoding
200        let json_str = serde_json::to_string(data)?;
201        Ok(*keccak256(json_str.as_bytes()))
202    }
203
204    fn create_eip712_hash(
205        &self,
206        domain_hash: &[u8; 32],
207        message_hash: &[u8; 32],
208    ) -> Result<[u8; 32]> {
209        // EIP-712 prefix: "\x19\x01" + domain_separator + message_hash
210        let mut combined = Vec::with_capacity(66);
211        combined.extend_from_slice(b"\x19\x01");
212        combined.extend_from_slice(domain_hash);
213        combined.extend_from_slice(message_hash);
214        Ok(*keccak256(&combined))
215    }
216
217    fn sign_hash(&self, hash: &[u8; 32]) -> Result<String> {
218        // Parse private key and create signer
219        let key_hex = self.private_key.as_hex();
220        let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
221
222        // Create PrivateKeySigner from hex string
223        let signer = PrivateKeySigner::from_str(key_hex)
224            .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
225
226        // Convert [u8; 32] to B256 for signing
227        let hash_b256 = B256::from(*hash);
228
229        // Sign the hash - alloy-signer handles the signing internally
230        let signature = signer
231            .sign_hash_sync(&hash_b256)
232            .map_err(|e| Error::transport(format!("Failed to sign hash: {e}")))?;
233
234        // Extract r, s, v components for Ethereum signature format
235        // Ethereum signature format: 0x + r (64 hex) + s (64 hex) + v (2 hex) = 132 total
236        let r = signature.r();
237        let s = signature.s();
238        let v = signature.v(); // Get the y_parity as bool (true = 1, false = 0)
239
240        // Convert v from bool to Ethereum recovery ID (27 or 28)
241        let v_byte = if v { 28u8 } else { 27u8 };
242
243        // Format as Ethereum signature: 0x + r + s + v (132 hex chars total)
244        Ok(format!("0x{r:064x}{s:064x}{v_byte:02x}"))
245    }
246
247    fn canonicalize_action(action: &Value) -> Result<Value> {
248        match action {
249            Value::Object(obj) => {
250                let mut canonicalized = serde_json::Map::new();
251                for (key, value) in obj {
252                    let canon_value = match key.as_str() {
253                        "destination" | "address" | "user" if value.is_string() => {
254                            Value::String(Self::canonicalize_address(value.as_str().unwrap()))
255                        }
256                        "amount" | "px" | "sz" | "price" | "size" if value.is_string() => {
257                            Value::String(Self::canonicalize_decimal(value.as_str().unwrap()))
258                        }
259                        _ => Self::canonicalize_action(value)?,
260                    };
261                    canonicalized.insert(key.clone(), canon_value);
262                }
263                Ok(Value::Object(canonicalized))
264            }
265            Value::Array(arr) => {
266                let canonicalized: Result<Vec<_>> =
267                    arr.iter().map(Self::canonicalize_action).collect();
268                Ok(Value::Array(canonicalized?))
269            }
270            _ => Ok(action.clone()),
271        }
272    }
273
274    fn canonicalize_address(addr: &str) -> String {
275        if addr.starts_with("0x") || addr.starts_with("0X") {
276            format!("0x{}", &addr[2..].to_lowercase())
277        } else {
278            format!("0x{}", addr.to_lowercase())
279        }
280    }
281
282    fn canonicalize_decimal(decimal: &str) -> String {
283        if let Ok(num) = decimal.parse::<f64>() {
284            if num.fract() == 0.0 {
285                format!("{num:.0}")
286            } else {
287                let trimmed = format!("{num}")
288                    .trim_end_matches('0')
289                    .trim_end_matches('.')
290                    .to_string();
291                if trimmed.is_empty() || trimmed == "-" {
292                    "0".to_string()
293                } else {
294                    trimmed
295                }
296            }
297        } else {
298            decimal.to_string()
299        }
300    }
301
302    pub fn address(&self) -> Result<String> {
303        // Derive Ethereum address from private key using alloy-signer
304        let key_hex = self.private_key.as_hex();
305        let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
306
307        // Create PrivateKeySigner from hex string
308        let signer = PrivateKeySigner::from_str(key_hex)
309            .map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
310
311        // Get address from signer and format it properly (not Debug format)
312        let address = format!("{:#x}", signer.address());
313        Ok(address)
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use rstest::rstest;
320    use serde_json::json;
321
322    use super::*;
323
324    #[rstest]
325    fn test_address_canonicalization() {
326        assert_eq!(
327            HyperliquidEip712Signer::canonicalize_address("0xABCDEF123456789"),
328            "0xabcdef123456789"
329        );
330        assert_eq!(
331            HyperliquidEip712Signer::canonicalize_address("ABCDEF123456789"),
332            "0xabcdef123456789"
333        );
334        assert_eq!(
335            HyperliquidEip712Signer::canonicalize_address("0XABCDEF123456789"),
336            "0xabcdef123456789"
337        );
338    }
339
340    #[rstest]
341    fn test_decimal_canonicalization() {
342        assert_eq!(
343            HyperliquidEip712Signer::canonicalize_decimal("100.000"),
344            "100"
345        );
346        assert_eq!(
347            HyperliquidEip712Signer::canonicalize_decimal("100.100"),
348            "100.1"
349        );
350        assert_eq!(HyperliquidEip712Signer::canonicalize_decimal("0.000"), "0");
351        assert_eq!(
352            HyperliquidEip712Signer::canonicalize_decimal("123.456"),
353            "123.456"
354        );
355        assert_eq!(
356            HyperliquidEip712Signer::canonicalize_decimal("123.450"),
357            "123.45"
358        );
359    }
360
361    #[rstest]
362    fn test_action_canonicalization() {
363        let action = json!({
364            "destination": "0xABCDEF123456789",
365            "amount": "100.000",
366            "other": "unchanged"
367        });
368
369        let canonicalized = HyperliquidEip712Signer::canonicalize_action(&action).unwrap();
370
371        assert_eq!(canonicalized["destination"], "0xabcdef123456789");
372        assert_eq!(canonicalized["amount"], "100");
373        assert_eq!(canonicalized["other"], "unchanged");
374    }
375
376    #[rstest]
377    fn test_sign_request_l1_action() {
378        let private_key = EvmPrivateKey::new(
379            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
380        )
381        .unwrap();
382        let signer = HyperliquidEip712Signer::new(private_key);
383
384        let request = SignRequest {
385            action: json!({
386                "type": "withdraw",
387                "destination": "0xABCDEF123456789",
388                "amount": "100.000"
389            }),
390            action_bytes: None,
391            time_nonce: TimeNonce::from_millis(1640995200000),
392            action_type: HyperliquidActionType::L1,
393            is_testnet: false,
394            vault_address: None,
395        };
396
397        let result = signer.sign(&request).unwrap();
398        // Verify signature format: 0x + 64 hex chars (r) + 64 hex chars (s) + 2 hex chars (v)
399        assert!(result.signature.starts_with("0x"));
400        assert_eq!(result.signature.len(), 132); // 0x + 130 hex chars
401    }
402
403    #[rstest]
404    fn test_sign_request_user_action() {
405        let private_key = EvmPrivateKey::new(
406            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(),
407        )
408        .unwrap();
409        let signer = HyperliquidEip712Signer::new(private_key);
410
411        let request = SignRequest {
412            action: json!({
413                "type": "order",
414                "coin": "BTC",
415                "px": "50000.00",
416                "sz": "0.1"
417            }),
418            action_bytes: None,
419            time_nonce: TimeNonce::from_millis(1640995200000),
420            action_type: HyperliquidActionType::UserSigned,
421            is_testnet: false,
422            vault_address: None,
423        };
424
425        let result = signer.sign(&request).unwrap();
426        // Verify signature format: 0x + 64 hex chars (r) + 64 hex chars (s) + 2 hex chars (v)
427        assert!(result.signature.starts_with("0x"));
428        assert_eq!(result.signature.len(), 132); // 0x + 130 hex chars
429    }
430}