use std::str::FromStr;
use alloy::{
signers::{SignerSync, local::PrivateKeySigner},
sol_types::{SolStruct, eip712_domain},
};
use alloy_primitives::{Address, B256, keccak256};
use nautilus_core::hex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{nonce::TimeNonce, types::HyperliquidActionType};
use crate::{
common::credential::EvmPrivateKey,
http::error::{Error, Result},
};
alloy::sol! {
#[derive(Debug, Serialize, Deserialize)]
struct Agent {
string source;
bytes32 connectionId;
}
}
#[derive(Debug, Clone)]
pub struct SignRequest {
pub action: Value, pub action_bytes: Option<Vec<u8>>, pub time_nonce: TimeNonce,
pub action_type: HyperliquidActionType,
pub is_testnet: bool,
pub vault_address: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SignatureBundle {
pub signature: String,
}
#[derive(Debug, Clone)]
pub struct HyperliquidEip712Signer {
private_key: EvmPrivateKey,
}
impl HyperliquidEip712Signer {
pub fn new(private_key: EvmPrivateKey) -> Self {
Self { private_key }
}
pub fn sign(&self, request: &SignRequest) -> Result<SignatureBundle> {
let signature = match request.action_type {
HyperliquidActionType::L1 => self.sign_l1_action(request)?,
HyperliquidActionType::UserSigned => {
return Err(Error::transport(
"UserSigned signing is not implemented; all exchange actions use L1",
));
}
};
Ok(SignatureBundle { signature })
}
pub fn sign_l1_action(&self, request: &SignRequest) -> Result<String> {
let connection_id = self.compute_connection_id(request)?;
let source = if request.is_testnet {
"b".to_string()
} else {
"a".to_string()
};
let agent = Agent {
source,
connectionId: connection_id,
};
let domain = eip712_domain! {
name: "Exchange",
version: "1",
chain_id: 1337,
verifying_contract: Address::ZERO,
};
let signing_hash = agent.eip712_signing_hash(&domain);
self.sign_hash(&signing_hash.0)
}
fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
let mut bytes = if let Some(action_bytes) = &request.action_bytes {
action_bytes.clone()
} else {
log::warn!(
"Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
);
rmp_serde::to_vec_named(&request.action)
.map_err(|e| Error::transport(format!("Failed to serialize action: {e}")))?
};
let timestamp = request.time_nonce.as_millis() as u64;
bytes.extend_from_slice(×tamp.to_be_bytes());
if let Some(vault_addr) = &request.vault_address {
bytes.push(1); let vault_hex = vault_addr.trim_start_matches("0x");
let vault_bytes = hex::decode(vault_hex)
.map_err(|e| Error::transport(format!("Invalid vault address: {e}")))?;
bytes.extend_from_slice(&vault_bytes);
} else {
bytes.push(0); }
Ok(keccak256(&bytes))
}
fn sign_hash(&self, hash: &[u8; 32]) -> Result<String> {
let key_hex = self.private_key.as_hex();
let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
let signer = PrivateKeySigner::from_str(key_hex)
.map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
let hash_b256 = B256::from(*hash);
let signature = signer
.sign_hash_sync(&hash_b256)
.map_err(|e| Error::transport(format!("Failed to sign hash: {e}")))?;
let r = signature.r();
let s = signature.s();
let v = signature.v();
let v_byte = if v { 28u8 } else { 27u8 };
Ok(format!("0x{r:064x}{s:064x}{v_byte:02x}"))
}
pub fn address(&self) -> Result<String> {
let key_hex = self.private_key.as_hex();
let key_hex = key_hex.strip_prefix("0x").unwrap_or(key_hex);
let signer = PrivateKeySigner::from_str(key_hex)
.map_err(|e| Error::transport(format!("Failed to create signer: {e}")))?;
let address = format!("{:#x}", signer.address());
Ok(address)
}
}
#[cfg(test)]
mod tests {
use alloy::sol_types::SolStruct;
use nautilus_model::{identifiers::ClientOrderId, types::Price};
use rstest::rstest;
use rust_decimal_macros::dec;
use serde_json::json;
use super::*;
use crate::http::models::{
Cloid, HyperliquidExecAction, HyperliquidExecGrouping, HyperliquidExecLimitParams,
HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest, HyperliquidExecTif,
};
#[rstest]
fn test_sign_request_l1_action() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(private_key);
let request = SignRequest {
action: json!({
"type": "withdraw",
"destination": "0xABCDEF123456789",
"amount": "100.000"
}),
action_bytes: None,
time_nonce: TimeNonce::from_millis(1640995200000),
action_type: HyperliquidActionType::L1,
is_testnet: false,
vault_address: None,
};
let result = signer.sign(&request).unwrap();
assert!(result.signature.starts_with("0x"));
assert_eq!(result.signature.len(), 132); }
#[rstest]
fn test_sign_user_signed_returns_error() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(private_key);
let request = SignRequest {
action: json!({"type": "order"}),
action_bytes: None,
time_nonce: TimeNonce::from_millis(1640995200000),
action_type: HyperliquidActionType::UserSigned,
is_testnet: false,
vault_address: None,
};
assert!(signer.sign(&request).is_err());
}
#[rstest]
fn test_connection_id_matches_python() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(private_key);
let typed_action = HyperliquidExecAction::Order {
orders: vec![HyperliquidExecPlaceOrderRequest {
asset: 0,
is_buy: true,
price: dec!(50000),
size: dec!(0.1),
reduce_only: false,
kind: HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams {
tif: HyperliquidExecTif::Gtc,
},
},
cloid: None,
}],
grouping: HyperliquidExecGrouping::Na,
builder: None,
};
let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
println!(
"Rust typed MsgPack bytes ({}): {}",
action_bytes.len(),
hex::encode(&action_bytes)
);
let python_msgpack = hex::decode(
"83a474797065a56f72646572a66f72646572739186a16100a162c3a170a53530303030a173a3302e31a172c2a17481a56c696d697481a3746966a3477463a867726f7570696e67a26e61",
)
.unwrap();
println!(
"Python MsgPack bytes ({}): {}",
python_msgpack.len(),
hex::encode(&python_msgpack)
);
assert_eq!(
hex::encode(&action_bytes),
hex::encode(&python_msgpack),
"MsgPack bytes should match Python"
);
let action_value = serde_json::to_value(&typed_action).unwrap();
let request = SignRequest {
action: action_value,
action_bytes: Some(action_bytes),
time_nonce: TimeNonce::from_millis(1640995200000),
action_type: HyperliquidActionType::L1,
is_testnet: true, vault_address: None,
};
let connection_id = signer.compute_connection_id(&request).unwrap();
println!(
"Rust Connection ID: {}",
hex::encode(connection_id.as_slice())
);
let expected_connection_id =
"207b9fb52defb524f5a7f1c80f069ff8b58556b018532401de0e1342bcb13b40";
assert_eq!(
hex::encode(connection_id.as_slice()),
expected_connection_id,
"Connection ID should match Python"
);
let source = "b".to_string(); let agent = Agent {
source,
connectionId: connection_id,
};
let domain = eip712_domain! {
name: "Exchange",
version: "1",
chain_id: 1337,
verifying_contract: Address::ZERO,
};
let signing_hash = agent.eip712_signing_hash(&domain);
println!(
"Rust EIP-712 signing hash: {}",
hex::encode(signing_hash.as_slice())
);
let expected_signing_hash =
"5242f54e0c01d3e7ef449f91b25c1a27802fdd221f7f24bc211da6bf7b847d8d";
assert_eq!(
hex::encode(signing_hash.as_slice()),
expected_signing_hash,
"EIP-712 signing hash should match Python"
);
}
#[rstest]
fn test_connection_id_with_cloid() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let _signer = HyperliquidEip712Signer::new(private_key);
let cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
println!("Cloid hex: {}", cloid.to_hex());
let typed_action = HyperliquidExecAction::Order {
orders: vec![HyperliquidExecPlaceOrderRequest {
asset: 0,
is_buy: true,
price: dec!(50000),
size: dec!(0.1),
reduce_only: false,
kind: HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams {
tif: HyperliquidExecTif::Gtc,
},
},
cloid: Some(cloid),
}],
grouping: HyperliquidExecGrouping::Na,
builder: None,
};
let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
println!(
"Rust MsgPack bytes with cloid ({}): {}",
action_bytes.len(),
hex::encode(&action_bytes)
);
let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
println!(
"Decoded structure: {}",
serde_json::to_string_pretty(&decoded).unwrap()
);
let orders = decoded.get("orders").unwrap().as_array().unwrap();
let first_order = &orders[0];
let cloid_field = first_order.get("c").unwrap();
println!("Cloid in msgpack: {cloid_field}");
assert_eq!(
cloid_field.as_str().unwrap(),
"0x1234567890abcdef1234567890abcdef"
);
let order_json = serde_json::to_string(first_order).unwrap();
println!("Order JSON: {order_json}");
}
#[rstest]
fn test_cloid_from_client_order_id() {
let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
let cloid = Cloid::from_client_order_id(client_order_id);
println!("ClientOrderId: {client_order_id}");
println!("Cloid hex: {}", cloid.to_hex());
let hex = cloid.to_hex();
assert!(hex.starts_with("0x"), "Should start with 0x");
assert_eq!(hex.len(), 34, "Should be 34 chars (0x + 32 hex)");
for c in hex[2..].chars() {
assert!(c.is_ascii_hexdigit(), "Should be hex digit: {c}");
}
let json = serde_json::to_string(&cloid).unwrap();
println!("Cloid JSON: {json}");
assert!(json.contains(&hex));
}
#[rstest]
fn test_production_like_order_with_hashed_cloid() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(private_key);
let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
let cloid = Cloid::from_client_order_id(client_order_id);
println!("=== Production-like Order ===");
println!("ClientOrderId: {client_order_id}");
println!("Cloid: {}", cloid.to_hex());
let typed_action = HyperliquidExecAction::Order {
orders: vec![HyperliquidExecPlaceOrderRequest {
asset: 3, is_buy: true,
price: dec!(92572.0),
size: dec!(0.001),
reduce_only: false,
kind: HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams {
tif: HyperliquidExecTif::Gtc,
},
},
cloid: Some(cloid),
}],
grouping: HyperliquidExecGrouping::Na,
builder: None,
};
let action_bytes = rmp_serde::to_vec_named(&typed_action).unwrap();
println!(
"MsgPack bytes ({}): {}",
action_bytes.len(),
hex::encode(&action_bytes)
);
let decoded: serde_json::Value = rmp_serde::from_slice(&action_bytes).unwrap();
println!(
"Decoded: {}",
serde_json::to_string_pretty(&decoded).unwrap()
);
let action_value = serde_json::to_value(&typed_action).unwrap();
let request = SignRequest {
action: action_value,
action_bytes: Some(action_bytes),
time_nonce: TimeNonce::from_millis(1733833200000), action_type: HyperliquidActionType::L1,
is_testnet: true, vault_address: None,
};
let connection_id = signer.compute_connection_id(&request).unwrap();
println!("Connection ID: {}", hex::encode(connection_id.as_slice()));
let source = "b".to_string();
let agent = Agent {
source,
connectionId: connection_id,
};
let domain = eip712_domain! {
name: "Exchange",
version: "1",
chain_id: 1337,
verifying_contract: Address::ZERO,
};
let signing_hash = agent.eip712_signing_hash(&domain);
println!("Signing hash: {}", hex::encode(signing_hash.as_slice()));
let result = signer.sign(&request).unwrap();
println!("Signature: {}", result.signature);
assert!(result.signature.starts_with("0x"));
assert_eq!(result.signature.len(), 132);
}
#[rstest]
fn test_price_decimal_formatting() {
let test_cases = [
(92572.0_f64, 1_u8, "92572"), (92572.5, 1, "92572.5"), (0.001, 8, "0.001"), (50000.0, 1, "50000"), (0.1, 4, "0.1"), ];
for (value, precision, expected_normalized) in test_cases {
let price = Price::new(value, precision);
let price_decimal = price.as_decimal();
let normalized = price_decimal.normalize();
println!(
"Price({value}, {precision}) -> as_decimal: {price_decimal:?} -> normalized: {normalized}"
);
assert_eq!(
normalized.to_string(),
expected_normalized,
"Price({value}, {precision}) should normalize to {expected_normalized}"
);
}
let price_from_type = Price::new(92572.0, 1).as_decimal().normalize();
let price_from_dec = dec!(92572.0).normalize();
assert_eq!(
price_from_type.to_string(),
price_from_dec.to_string(),
"Price::as_decimal should match dec! macro"
);
}
}