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