Skip to main content

nautilus_hyperliquid/signing/
signers.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::str::FromStr;
17
18use alloy::{
19    signers::{SignerSync, local::PrivateKeySigner},
20    sol_types::{Eip712Domain, SolStruct, eip712_domain},
21};
22use alloy_primitives::{Address, B256, Keccak256};
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use super::{nonce::TimeNonce, types::HyperliquidActionType};
27use crate::{
28    common::credential::{EvmPrivateKey, VaultAddress},
29    http::{
30        error::{Error, Result},
31        models::HyperliquidSignature,
32    },
33};
34
35// Define the Agent struct for L1 signing
36alloy::sol! {
37    #[derive(Debug, Serialize, Deserialize)]
38    struct Agent {
39        string source;
40        bytes32 connectionId;
41    }
42}
43
44/// Request to be signed by the Hyperliquid EIP-712 signer.
45///
46/// For L1 actions, populate `action_bytes` with the pre-serialized MessagePack
47/// of the typed action; `action` may be `None`. The `action` JSON value is only
48/// consumed as a fallback when `action_bytes` is `None` (kept for ad-hoc test
49/// payloads built via `json!`).
50#[derive(Debug, Clone)]
51pub struct SignRequest {
52    pub action: Option<Value>,         // Fallback when action_bytes is None
53    pub action_bytes: Option<Vec<u8>>, // Pre-serialized MessagePack (preferred)
54    pub time_nonce: TimeNonce,
55    pub action_type: HyperliquidActionType,
56    pub is_testnet: bool,
57    pub vault_address: Option<VaultAddress>,
58    pub expires_after: Option<u64>,
59}
60
61/// Bundle containing signature for Hyperliquid requests.
62#[derive(Debug, Clone)]
63pub struct SignatureBundle {
64    pub signature: HyperliquidSignature,
65}
66
67/// EIP-712 signer for Hyperliquid.
68#[derive(Debug, Clone)]
69pub struct HyperliquidEip712Signer {
70    signer: PrivateKeySigner,
71    address: String,
72    domain: Eip712Domain,
73}
74
75impl HyperliquidEip712Signer {
76    /// Creates a new [`HyperliquidEip712Signer`].
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if the private key cannot be parsed.
81    pub fn new(private_key: &EvmPrivateKey) -> Result<Self> {
82        let key_hex = private_key.as_hex();
83        let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
84
85        let signer = PrivateKeySigner::from_str(key_hex)
86            .map_err(|e| Error::auth(format!("Failed to create signer: {e}")))?;
87
88        let address = format!("{:#x}", signer.address());
89
90        let domain = eip712_domain! {
91            name: "Exchange",
92            version: "1",
93            chain_id: 1337,
94            verifying_contract: Address::ZERO,
95        };
96
97        Ok(Self {
98            signer,
99            address,
100            domain,
101        })
102    }
103
104    pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
105        let signature = match request.action_type {
106            HyperliquidActionType::L1 => self.sign_l1_action(request)?,
107            HyperliquidActionType::UserSigned => {
108                return Err(Error::bad_request(
109                    "UserSigned signing is not implemented; all exchange actions use L1",
110                ));
111            }
112        };
113
114        Ok(SignatureBundle { signature })
115    }
116
117    pub fn sign_l1_action(&self, request: &SignRequest) -> Result<HyperliquidSignature> {
118        // L1 signing for Hyperliquid follows this pattern:
119        // 1. Serialize action with MessagePack (rmp_serde)
120        // 2. Append timestamp, vault info, and optional expiry
121        // 3. Hash with keccak256 to get connection_id
122        // 4. Create Agent struct with source + connection_id
123        // 5. Sign Agent with EIP-712
124
125        // Step 1-3: Create connection_id
126        let connection_id = self.compute_connection_id(request)?;
127
128        // Step 4: Create Agent struct
129        let source = if request.is_testnet { "b" } else { "a" };
130
131        let agent = Agent {
132            source: source.to_string(),
133            connectionId: connection_id,
134        };
135
136        // Step 5: Sign Agent with EIP-712
137        let signing_hash = agent.eip712_signing_hash(&self.domain);
138
139        self.sign_hash(&signing_hash.0)
140    }
141
142    fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
143        let mut hasher = Keccak256::new();
144
145        if let Some(action_bytes) = &request.action_bytes {
146            hasher.update(action_bytes);
147        } else {
148            log::warn!(
149                "Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
150            );
151            let action = request.action.as_ref().ok_or_else(|| {
152                Error::bad_request("SignRequest has neither action_bytes nor action")
153            })?;
154            let action_bytes = rmp_serde::to_vec_named(action)
155                .map_err(|e| Error::bad_request(format!("Failed to serialize action: {e}")))?;
156            hasher.update(&action_bytes);
157        }
158
159        let timestamp = request.time_nonce.as_millis() as u64;
160        hasher.update(timestamp.to_be_bytes());
161
162        if let Some(vault_addr) = request.vault_address {
163            hasher.update([1u8]);
164            hasher.update(vault_addr.as_bytes());
165        } else {
166            hasher.update([0u8]);
167        }
168
169        if let Some(expires_after) = request.expires_after {
170            hasher.update([0u8]);
171            hasher.update(expires_after.to_be_bytes());
172        }
173
174        Ok(hasher.finalize())
175    }
176
177    fn sign_hash(&self, hash: &[u8; 32]) -> Result<HyperliquidSignature> {
178        let hash_b256 = B256::from(*hash);
179
180        let signature = self
181            .signer
182            .sign_hash_sync(&hash_b256)
183            .map_err(|e| Error::auth(format!("Failed to sign hash: {e}")))?;
184
185        let r = signature.r();
186        let s = signature.s();
187        let v = signature.v();
188        let v_byte = if v { 28u8 } else { 27u8 };
189
190        Ok(HyperliquidSignature::new(
191            format!("0x{r:064x}"),
192            format!("0x{s:064x}"),
193            v_byte as u64,
194        ))
195    }
196
197    /// Returns the signer's Ethereum address.
198    pub fn address(&self) -> Result<String> {
199        Ok(self.address.clone())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use ahash::AHashSet;
206    use alloy::sol_types::SolStruct;
207    use nautilus_core::hex;
208    use nautilus_model::{identifiers::ClientOrderId, types::Price};
209    use rstest::rstest;
210    use rust_decimal_macros::dec;
211    use serde_json::json;
212
213    use super::*;
214    use crate::http::models::{
215        Cloid, HyperliquidExecAction, HyperliquidExecGrouping, HyperliquidExecLimitParams,
216        HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest, HyperliquidExecTif,
217    };
218
219    #[rstest]
220    fn test_sign_request_l1_action() {
221        let private_key = EvmPrivateKey::new(
222            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
223        )
224        .unwrap();
225        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
226
227        let request = SignRequest {
228            action: Some(json!({
229                "type": "withdraw",
230                "destination": "0xABCDEF123456789",
231                "amount": "100.000"
232            })),
233            action_bytes: None,
234            time_nonce: TimeNonce::from_millis(1640995200000),
235            action_type: HyperliquidActionType::L1,
236            is_testnet: false,
237            vault_address: None,
238            expires_after: None,
239        };
240
241        let result = signer.sign(&request).unwrap();
242        let sig_hex = result.signature.to_hex();
243        // Verify signature format: 0x + 64 hex chars (r) + 64 hex chars (s) + 2 hex chars (v)
244        assert!(sig_hex.starts_with("0x"));
245        assert_eq!(sig_hex.len(), 132); // 0x + 130 hex chars
246    }
247
248    // L1 sign with neither field set must error, not panic on missing input
249    #[rstest]
250    fn test_sign_l1_rejects_when_action_and_bytes_missing() {
251        let private_key = EvmPrivateKey::new(
252            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
253        )
254        .unwrap();
255        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
256
257        let request = SignRequest {
258            action: None,
259            action_bytes: None,
260            time_nonce: TimeNonce::from_millis(1640995200000),
261            action_type: HyperliquidActionType::L1,
262            is_testnet: false,
263            vault_address: None,
264            expires_after: None,
265        };
266
267        let err = signer.sign(&request).unwrap_err();
268        assert!(
269            matches!(err, Error::BadRequest(_)),
270            "expected BadRequest, was {err:?}",
271        );
272    }
273
274    #[rstest]
275    fn test_sign_user_signed_returns_error() {
276        let private_key = EvmPrivateKey::new(
277            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
278        )
279        .unwrap();
280        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
281
282        let request = SignRequest {
283            action: Some(json!({"type": "order"})),
284            action_bytes: None,
285            time_nonce: TimeNonce::from_millis(1640995200000),
286            action_type: HyperliquidActionType::UserSigned,
287            is_testnet: false,
288            vault_address: None,
289            expires_after: None,
290        };
291
292        let err = signer.sign(&request).unwrap_err();
293        assert!(
294            matches!(err, Error::BadRequest(_)),
295            "expected BadRequest, was {err:?}"
296        );
297    }
298
299    #[rstest]
300    fn test_connection_id_matches_python() {
301        // Test that our connection_id computation matches Python SDK exactly.
302        // Python expected output for this test case:
303        // MsgPack bytes: 83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61
304        // Connection ID: 207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40
305
306        let private_key = EvmPrivateKey::new(
307            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
308        )
309        .unwrap();
310        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
311
312        // NOTE: json! macro sorts keys alphabetically, but Python preserves insertion order.
313        // Field order: Python uses "type", "orders", "grouping"
314        // json! produces: "grouping", "orders", "type" (alphabetical)
315        // This causes hash mismatch!
316        //
317        // When using typed structs (HyperliquidExecAction), serde follows declaration order.
318        // Let's test with the typed struct approach.
319
320        let typed_action = HyperliquidExecAction::Order {
321            orders: vec![HyperliquidExecPlaceOrderRequest {
322                asset: 0,
323                is_buy: true,
324                price: dec!(50000),
325                size: dec!(0.1),
326                reduce_only: false,
327                kind: HyperliquidExecOrderKind::Limit {
328                    limit: HyperliquidExecLimitParams {
329                        tif: HyperliquidExecTif::Gtc,
330                    },
331                },
332                cloid: None,
333            }],
334            grouping: HyperliquidExecGrouping::Na,
335            builder: None,
336        };
337
338        // Serialize the typed struct with msgpack
339        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
340        println!(
341            "Rust typed MsgPack bytes ({}): {}",
342            action_bytes.len(),
343            hex::encode(&action_bytes)
344        );
345
346        // Expected from Python
347        let python_msgpack = hex::decode(
348            "83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
349        )
350        .unwrap();
351        println!(
352            "Python MsgPack bytes ({}): {}",
353            python_msgpack.len(),
354            hex::encode(&python_msgpack)
355        );
356
357        // Compare msgpack bytes
358        assert_eq!(
359            hex::encode(&action_bytes),
360            hex::encode(&python_msgpack),
361            "MsgPack bytes should match Python"
362        );
363
364        // Now test the full connection_id computation
365        let request = SignRequest {
366            action: None,
367            action_bytes: Some(action_bytes),
368            time_nonce: TimeNonce::from_millis(1640995200000),
369            action_type: HyperliquidActionType::L1,
370            is_testnet: true, // source = "b"
371            vault_address: None,
372            expires_after: None,
373        };
374
375        let connection_id = signer.compute_connection_id(&request).unwrap();
376        println!(
377            "Rust Connection ID: {}",
378            hex::encode(connection_id.as_slice())
379        );
380
381        // Expected from Python
382        let expected_connection_id =
383            "207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
384        assert_eq!(
385            hex::encode(connection_id.as_slice()),
386            expected_connection_id,
387            "Connection ID should match Python"
388        );
389
390        // Now test the full signing hash
391        // Python expected values:
392        // Domain separator: d79297fcdf2ffcd4ae223d01edaa2ba214ff8f401d7c9300d995d17c82aa4040
393        // Struct hash: 99c7d776d74816c42973fbe58bb0f0d03c80324bef180220196d0dccf01672c5
394        // Signing hash: 5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d
395
396        // Create Agent and sign - matching our sign_l1_action logic
397        let source = "b".to_string(); // is_testnet = true
398        let agent = Agent {
399            source,
400            connectionId: connection_id,
401        };
402
403        let domain = eip712_domain! {
404            name: "Exchange",
405            version: "1",
406            chain_id: 1337,
407            verifying_contract: Address::ZERO,
408        };
409
410        let signing_hash = agent.eip712_signing_hash(&domain);
411        println!(
412            "Rust EIP-712 signing hash: {}",
413            hex::encode(signing_hash.as_slice())
414        );
415
416        // Expected from Python
417        let expected_signing_hash =
418            "5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
419        assert_eq!(
420            hex::encode(signing_hash.as_slice()),
421            expected_signing_hash,
422            "EIP-712 signing hash should match Python"
423        );
424    }
425
426    #[rstest]
427    fn test_connection_id_includes_expires_after_when_present() {
428        let private_key = EvmPrivateKey::new(
429            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
430        )
431        .unwrap();
432        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
433
434        let typed_action = HyperliquidExecAction::Order {
435            orders: vec![HyperliquidExecPlaceOrderRequest {
436                asset: 0,
437                is_buy: true,
438                price: dec!(50000),
439                size: dec!(0.1),
440                reduce_only: false,
441                kind: HyperliquidExecOrderKind::Limit {
442                    limit: HyperliquidExecLimitParams {
443                        tif: HyperliquidExecTif::Gtc,
444                    },
445                },
446                cloid: None,
447            }],
448            grouping: HyperliquidExecGrouping::Na,
449            builder: None,
450        };
451        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
452
453        let without_expiry = SignRequest {
454            action: None,
455            action_bytes: Some(action_bytes),
456            time_nonce: TimeNonce::from_millis(1640995200000),
457            action_type: HyperliquidActionType::L1,
458            is_testnet: true,
459            vault_address: None,
460            expires_after: None,
461        };
462        let with_expiry = SignRequest {
463            expires_after: Some(1640995260000),
464            ..without_expiry.clone()
465        };
466
467        let without_expiry_id = signer.compute_connection_id(&without_expiry).unwrap();
468        let with_expiry_id = signer.compute_connection_id(&with_expiry).unwrap();
469
470        assert_ne!(
471            without_expiry_id, with_expiry_id,
472            "expiresAfter must be part of the L1 action hash",
473        );
474    }
475
476    #[rstest]
477    fn test_connection_id_with_vault_matches_reference() {
478        let private_key = EvmPrivateKey::new(
479            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
480        )
481        .unwrap();
482        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
483
484        let typed_action = HyperliquidExecAction::Order {
485            orders: vec![HyperliquidExecPlaceOrderRequest {
486                asset: 0,
487                is_buy: true,
488                price: dec!(50000),
489                size: dec!(0.1),
490                reduce_only: false,
491                kind: HyperliquidExecOrderKind::Limit {
492                    limit: HyperliquidExecLimitParams {
493                        tif: HyperliquidExecTif::Gtc,
494                    },
495                },
496                cloid: None,
497            }],
498            grouping: HyperliquidExecGrouping::Na,
499            builder: None,
500        };
501        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
502        let request = SignRequest {
503            action: None,
504            action_bytes: Some(action_bytes),
505            time_nonce: TimeNonce::from_millis(1640995200000),
506            action_type: HyperliquidActionType::L1,
507            is_testnet: true,
508            vault_address: Some(
509                VaultAddress::parse("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01").unwrap(),
510            ),
511            expires_after: None,
512        };
513
514        let connection_id = signer.compute_connection_id(&request).unwrap();
515
516        assert_eq!(
517            hex::encode(connection_id.as_slice()),
518            "edc33e36cec99166e20ea113da7e7b028cb94efda22813f814752d719a272757",
519            "connection ID must match the L1 vault signing reference",
520        );
521    }
522
523    #[rstest]
524    fn test_connection_id_with_cloid() {
525        // Test with CLOID included - this is what production actually sends.
526        // The key difference: production always includes a cloid field.
527
528        let private_key = EvmPrivateKey::new(
529            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
530        )
531        .unwrap();
532        let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
533
534        // Create a cloid - this is how Python SDK expects it
535        let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
536        println!("Cloid hex: {}", cloid.to_hex());
537
538        let typed_action = HyperliquidExecAction::Order {
539            orders: vec![HyperliquidExecPlaceOrderRequest {
540                asset: 0,
541                is_buy: true,
542                price: dec!(50000),
543                size: dec!(0.1),
544                reduce_only: false,
545                kind: HyperliquidExecOrderKind::Limit {
546                    limit: HyperliquidExecLimitParams {
547                        tif: HyperliquidExecTif::Gtc,
548                    },
549                },
550                cloid: Some(cloid),
551            }],
552            grouping: HyperliquidExecGrouping::Na,
553            builder: None,
554        };
555
556        // Serialize the typed struct with msgpack
557        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
558        println!(
559            "Rust MsgPack bytes with cloid ({}): {}",
560            action_bytes.len(),
561            hex::encode(&action_bytes)
562        );
563
564        // Decode to see the structure
565        let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
566        println!(
567            "Decoded structure: {}",
568            serde_json::to_string_pretty(&decoded).unwrap()
569        );
570
571        // Verify the cloid is in the right place
572        let orders = decoded.get("orders").unwrap().as_array().unwrap();
573        let first_order = &orders[0];
574        let cloid_field = first_order.get("c").unwrap();
575        println!("Cloid in msgpack: {cloid_field}");
576        assert_eq!(
577            cloid_field.as_str().unwrap(),
578            "0x1234567890abcdef1234567890abcdef"
579        );
580
581        // Verify order field order is correct: a, b, p, s, r, t, c
582        let order_json = serde_json::to_string(first_order).unwrap();
583        println!("Order JSON: {order_json}");
584    }
585
586    #[rstest]
587    fn test_cloid_from_client_order_id_is_deterministic() {
588        let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
589        let other_client_order_id = ClientOrderId::from("O-20241210-123456-001-001-2");
590        let first = Cloid::from_client_order_id(client_order_id);
591        let second = Cloid::from_client_order_id(client_order_id);
592        let other = Cloid::from_client_order_id(other_client_order_id);
593
594        let first_hex = first.to_hex();
595        let second_hex = second.to_hex();
596        let other_hex = other.to_hex();
597
598        for hex in [&first_hex, &second_hex, &other_hex] {
599            assert!(hex.starts_with("0x"));
600            assert_eq!(hex.len(), 34);
601            assert!(hex[2..].chars().all(|c| c.is_ascii_hexdigit()));
602            assert!(hex[2..].chars().all(|c| !c.is_ascii_uppercase()));
603        }
604
605        assert_eq!(first_hex, "0x7824fcada984a4aa731780e8326c1932");
606        assert_eq!(other_hex, "0x9012504833e63da1435c32e96ef8b873");
607        assert_eq!(first, second);
608        assert_ne!(first, other);
609    }
610
611    #[rstest]
612    fn test_cloid_from_client_order_id_has_varied_leading_bytes() {
613        let cloids: Vec<_> = (0..100)
614            .map(|i| {
615                let client_order_id = ClientOrderId::from(format!("O-SAMPLE-{i:03}").as_str());
616                Cloid::from_client_order_id(client_order_id)
617            })
618            .collect();
619
620        let leading_bytes = cloids
621            .iter()
622            .map(|cloid| cloid.0[0])
623            .collect::<AHashSet<_>>();
624
625        let uuid_like = cloids.iter().filter(|cloid| cloid.is_uuid_v4()).count();
626        assert!(uuid_like < cloids.len());
627        assert!(leading_bytes.len() > 1);
628
629        let unique = cloids.iter().collect::<AHashSet<_>>();
630        assert_eq!(unique.len(), cloids.len());
631    }
632
633    #[rstest]
634    fn test_production_like_order_with_deterministic_cloid() {
635        let private_key = EvmPrivateKey::new(
636            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
637        )
638        .unwrap();
639        let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
640
641        // Production-like values
642        let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
643        let cloid = Cloid::from_client_order_id(client_order_id);
644
645        println!("=== Production-like Order ===");
646        println!("ClientOrderId: {client_order_id}");
647        println!("Cloid: {}", cloid.to_hex());
648
649        let typed_action = HyperliquidExecAction::Order {
650            orders: vec![HyperliquidExecPlaceOrderRequest {
651                asset: 3, // BTC on testnet
652                is_buy: true,
653                price: dec!(92572.0),
654                size: dec!(0.001),
655                reduce_only: false,
656                kind: HyperliquidExecOrderKind::Limit {
657                    limit: HyperliquidExecLimitParams {
658                        tif: HyperliquidExecTif::Gtc,
659                    },
660                },
661                cloid: Some(cloid),
662            }],
663            grouping: HyperliquidExecGrouping::Na,
664            builder: None,
665        };
666
667        // Serialize with msgpack
668        let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
669        println!(
670            "MsgPack bytes ({}): {}",
671            action_bytes.len(),
672            hex::encode(&action_bytes)
673        );
674
675        // Decode to verify structure
676        let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
677        println!(
678            "Decoded: {}",
679            serde_json::to_string_pretty(&decoded).unwrap()
680        );
681
682        // Compute connection_id and signing hash
683        let request = SignRequest {
684            action: None,
685            action_bytes: Some(action_bytes),
686            time_nonce: TimeNonce::from_millis(1733833200000), // Dec 10, 2024
687            action_type: HyperliquidActionType::L1,
688            is_testnet: true, // source = "b"
689            vault_address: None,
690            expires_after: None,
691        };
692
693        let connection_id = signer.compute_connection_id(&request).unwrap();
694        println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
695
696        // Create Agent and get signing hash
697        let source = "b".to_string();
698        let agent = Agent {
699            source,
700            connectionId: connection_id,
701        };
702
703        let domain = eip712_domain! {
704            name: "Exchange",
705            version: "1",
706            chain_id: 1337,
707            verifying_contract: Address::ZERO,
708        };
709
710        let signing_hash = agent.eip712_signing_hash(&domain);
711        println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
712
713        // Sign and verify signature format
714        let result = signer.sign(&request).unwrap();
715        let sig_hex = result.signature.to_hex();
716        println!("Signature: {sig_hex}");
717        assert!(sig_hex.starts_with("0x"));
718        assert_eq!(sig_hex.len(), 132);
719    }
720
721    #[rstest]
722    fn test_price_decimal_formatting() {
723        // Compare how Price::as_decimal() formats vs dec!() macro
724        // Test various price formats
725        let test_cases = [
726            (92572.0_f64, 1_u8, "92572"), // BTC price
727            (92572.5, 1, "92572.5"),      // BTC price with fractional
728            (0.001, 8, "0.001"),          // Small qty
729            (50000.0, 1, "50000"),        // Round number
730            (0.1, 4, "0.1"),              // Typical qty
731        ];
732
733        for (value, precision, expected_normalized) in test_cases {
734            let price = Price::new(value, precision);
735            let price_decimal = price.as_decimal();
736            let normalized = price_decimal.normalize();
737
738            println!(
739                "Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
740            );
741
742            assert_eq!(
743                normalized.to_string(),
744                expected_normalized,
745                "Price({value}, {precision}) should normalize to {expected_normalized}"
746            );
747        }
748
749        // Verify dec! macro produces same result
750        let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
751        let price_from_dec = dec!(92572.0).normalize();
752        assert_eq!(
753            price_from_type.to_string(),
754            price_from_dec.to_string(),
755            "Price::as_decimal should match dec! macro"
756        );
757    }
758}