use std::str::FromStr;
use alloy::{
signers::{SignerSync, local::PrivateKeySigner},
sol_types::{Eip712Domain, SolStruct, eip712_domain},
};
use alloy_primitives::{Address, B256, Keccak256};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{nonce::TimeNonce, types::HyperliquidActionType};
use crate::{
common::credential::{EvmPrivateKey, VaultAddress},
http::{
error::{Error, Result},
models::HyperliquidSignature,
},
};
alloy::sol! {
#[derive(Debug, Serialize, Deserialize)]
struct Agent {
string source;
bytes32 connectionId;
}
}
#[derive(Debug, Clone)]
pub struct SignRequest {
pub action: Option<Value>, pub action_bytes: Option<Vec<u8>>, pub time_nonce: TimeNonce,
pub action_type: HyperliquidActionType,
pub is_testnet: bool,
pub vault_address: Option<VaultAddress>,
pub expires_after: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct SignatureBundle {
pub signature: HyperliquidSignature,
}
#[derive(Debug, Clone)]
pub struct HyperliquidEip712Signer {
signer: PrivateKeySigner,
address: String,
domain: Eip712Domain,
}
impl HyperliquidEip712Signer {
pub fn new(private_key: &EvmPrivateKey) -> Result<Self> {
let key_hex = 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::auth(format!("Failed to create signer: {e}")))?;
let address = format!("{:#x}", signer.address());
let domain = eip712_domain! {
name: "Exchange",
version: "1",
chain_id: 1337,
verifying_contract: Address::ZERO,
};
Ok(Self {
signer,
address,
domain,
})
}
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::bad_request(
"UserSigned signing is not implemented; all exchange actions use L1",
));
}
};
Ok(SignatureBundle { signature })
}
pub fn sign_l1_action(&self, request: &SignRequest) -> Result<HyperliquidSignature> {
let connection_id = self.compute_connection_id(request)?;
let source = if request.is_testnet { "b" } else { "a" };
let agent = Agent {
source: source.to_string(),
connectionId: connection_id,
};
let signing_hash = agent.eip712_signing_hash(&self.domain);
self.sign_hash(&signing_hash.0)
}
fn compute_connection_id(&self, request: &SignRequest) -> Result<B256> {
let mut hasher = Keccak256::new();
if let Some(action_bytes) = &request.action_bytes {
hasher.update(action_bytes);
} else {
log::warn!(
"Falling back to JSON Value msgpack serialization - this may cause hash mismatch!"
);
let action = request.action.as_ref().ok_or_else(|| {
Error::bad_request("SignRequest has neither action_bytes nor action")
})?;
let action_bytes = rmp_serde::to_vec_named(action)
.map_err(|e| Error::bad_request(format!("Failed to serialize action: {e}")))?;
hasher.update(&action_bytes);
}
let timestamp = request.time_nonce.as_millis() as u64;
hasher.update(timestamp.to_be_bytes());
if let Some(vault_addr) = request.vault_address {
hasher.update([1u8]);
hasher.update(vault_addr.as_bytes());
} else {
hasher.update([0u8]);
}
if let Some(expires_after) = request.expires_after {
hasher.update([0u8]);
hasher.update(expires_after.to_be_bytes());
}
Ok(hasher.finalize())
}
fn sign_hash(&self, hash: &[u8; 32]) -> Result<HyperliquidSignature> {
let hash_b256 = B256::from(*hash);
let signature = self
.signer
.sign_hash_sync(&hash_b256)
.map_err(|e| Error::auth(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(HyperliquidSignature::new(
format!("0x{r:064x}"),
format!("0x{s:064x}"),
v_byte as u64,
))
}
pub fn address(&self) -> Result<String> {
Ok(self.address.clone())
}
}
#[cfg(test)]
mod tests {
use alloy::sol_types::SolStruct;
use nautilus_core::hex;
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,
};
const CLOID_MARKER_PREFIX_BYTES: [u8; 2] = [0x6e, 0x42];
const CLOID_MARKER_PREFIX_HEX: &str = "6e42";
#[rstest]
fn test_sign_request_l1_action() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
let request = SignRequest {
action: Some(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,
expires_after: None,
};
let result = signer.sign(&request).unwrap();
let sig_hex = result.signature.to_hex();
assert!(sig_hex.starts_with("0x"));
assert_eq!(sig_hex.len(), 132); }
#[rstest]
fn test_sign_l1_rejects_when_action_and_bytes_missing() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
let request = SignRequest {
action: None,
action_bytes: None,
time_nonce: TimeNonce::from_millis(1640995200000),
action_type: HyperliquidActionType::L1,
is_testnet: false,
vault_address: None,
expires_after: None,
};
let err = signer.sign(&request).unwrap_err();
assert!(
matches!(err, Error::BadRequest(_)),
"expected BadRequest, was {err:?}",
);
}
#[rstest]
fn test_sign_user_signed_returns_error() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
let request = SignRequest {
action: Some(json!({"type": "order"})),
action_bytes: None,
time_nonce: TimeNonce::from_millis(1640995200000),
action_type: HyperliquidActionType::UserSigned,
is_testnet: false,
vault_address: None,
expires_after: None,
};
let err = signer.sign(&request).unwrap_err();
assert!(
matches!(err, Error::BadRequest(_)),
"expected BadRequest, was {err:?}"
);
}
#[rstest]
fn test_connection_id_matches_python() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
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 request = SignRequest {
action: None,
action_bytes: Some(action_bytes),
time_nonce: TimeNonce::from_millis(1640995200000),
action_type: HyperliquidActionType::L1,
is_testnet: true, vault_address: None,
expires_after: 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_includes_expires_after_when_present() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
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();
let without_expiry = SignRequest {
action: None,
action_bytes: Some(action_bytes),
time_nonce: TimeNonce::from_millis(1640995200000),
action_type: HyperliquidActionType::L1,
is_testnet: true,
vault_address: None,
expires_after: None,
};
let with_expiry = SignRequest {
expires_after: Some(1640995260000),
..without_expiry.clone()
};
let without_expiry_id = signer.compute_connection_id(&without_expiry).unwrap();
let with_expiry_id = signer.compute_connection_id(&with_expiry).unwrap();
assert_ne!(
without_expiry_id, with_expiry_id,
"expiresAfter must be part of the L1 action hash",
);
}
#[rstest]
fn test_connection_id_with_vault_matches_reference() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
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();
let request = SignRequest {
action: None,
action_bytes: Some(action_bytes),
time_nonce: TimeNonce::from_millis(1640995200000),
action_type: HyperliquidActionType::L1,
is_testnet: true,
vault_address: Some(
VaultAddress::parse("0xAbCdEf0123456789AbCdEf0123456789AbCdEf01").unwrap(),
),
expires_after: None,
};
let connection_id = signer.compute_connection_id(&request).unwrap();
assert_eq!(
hex::encode(connection_id.as_slice()),
"edc33e36cec99166e20ea113da7e7b028cb94efda22813f814752d719a272757",
"connection ID must match the L1 vault signing reference",
);
}
#[rstest]
fn test_connection_id_with_cloid() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let _signer = HyperliquidEip712Signer::new(&private_key).unwrap();
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_is_deterministic_and_marked() {
let client_order_id = ClientOrderId::from("O-20241210-123456-001-001-1");
let other_client_order_id = ClientOrderId::from("O-20241210-123456-001-001-2");
let first = Cloid::from_client_order_id(client_order_id);
let second = Cloid::from_client_order_id(client_order_id);
let other = Cloid::from_client_order_id(other_client_order_id);
let first_hex = first.to_hex();
let second_hex = second.to_hex();
let other_hex = other.to_hex();
for hex in [&first_hex, &second_hex, &other_hex] {
assert!(hex.starts_with("0x"));
assert_eq!(hex.len(), 34);
assert_eq!(&hex[2..6], CLOID_MARKER_PREFIX_HEX);
assert!(hex[2..].chars().all(|c| c.is_ascii_hexdigit()));
assert!(hex[2..].chars().all(|c| !c.is_ascii_uppercase()));
assert_eq!(hex.as_bytes()[14], b'4');
assert!(matches!(hex.as_bytes()[18], b'8' | b'9' | b'a' | b'b'));
}
assert!(first.is_uuid_v4());
assert!(second.is_uuid_v4());
assert!(other.is_uuid_v4());
assert_eq!(
first.0[..CLOID_MARKER_PREFIX_BYTES.len()],
CLOID_MARKER_PREFIX_BYTES,
);
assert_eq!(
second.0[..CLOID_MARKER_PREFIX_BYTES.len()],
CLOID_MARKER_PREFIX_BYTES,
);
assert_eq!(first, second);
assert_ne!(first, other);
}
#[rstest]
fn test_cloid_from_client_order_id_has_stable_marker_across_sample() {
let cloids: Vec<_> = (0..100)
.map(|i| {
let client_order_id = ClientOrderId::from(format!("O-SAMPLE-{i:03}").as_str());
Cloid::from_client_order_id(client_order_id)
})
.collect();
for cloid in &cloids {
let hex = cloid.to_hex();
assert_eq!(&hex[2..6], CLOID_MARKER_PREFIX_HEX);
assert_eq!(
cloid.0[..CLOID_MARKER_PREFIX_BYTES.len()],
CLOID_MARKER_PREFIX_BYTES,
);
assert!(cloid.is_uuid_v4());
}
let unique = cloids.iter().collect::<std::collections::HashSet<_>>();
assert_eq!(unique.len(), cloids.len());
}
#[rstest]
fn test_production_like_order_with_deterministic_cloid() {
let private_key = EvmPrivateKey::new(
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap();
let signer = HyperliquidEip712Signer::new(&private_key).unwrap();
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 request = SignRequest {
action: None,
action_bytes: Some(action_bytes),
time_nonce: TimeNonce::from_millis(1733833200000), action_type: HyperliquidActionType::L1,
is_testnet: true, vault_address: None,
expires_after: 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();
let sig_hex = result.signature.to_hex();
println!("Signature: {sig_hex}");
assert!(sig_hex.starts_with("0x"));
assert_eq!(sig_hex.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"
);
}
}