use k256::ecdsa::SigningKey;
#[cfg(test)]
use rust_decimal::Decimal;
use serde::Serialize;
use sha3::{Digest, Keccak256};
use crate::error::{Error, Result};
use super::HyperliquidNetwork;
use super::parse::EXCHANGE;
const AGENT_CHAIN_ID: u64 = 1337;
#[cfg(test)]
const USER_SIGNED_CHAIN_ID: u64 = 0x66eee;
impl HyperliquidNetwork {
const fn agent_source(self) -> &'static str {
match self {
Self::Mainnet => "a",
Self::Testnet => "b",
}
}
#[cfg(test)]
const fn user_chain(self) -> &'static str {
match self {
Self::Mainnet => "Mainnet",
Self::Testnet => "Testnet",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct OrderWire {
pub(crate) a: u32,
pub(crate) b: bool,
pub(crate) p: String,
pub(crate) s: String,
pub(crate) r: bool,
pub(crate) t: OrderKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct OrderKind {
pub(crate) limit: LimitKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct LimitKind {
pub(crate) tif: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct OrderAction {
#[serde(rename = "type")]
pub(crate) action_type: &'static str,
pub(crate) orders: Vec<OrderWire>,
pub(crate) grouping: &'static str,
}
impl OrderAction {
pub(crate) fn new(order: OrderWire) -> Self {
Self {
action_type: "order",
orders: vec![order],
grouping: "na",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct CancelWire {
pub(crate) a: u32,
pub(crate) o: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct CancelAction {
#[serde(rename = "type")]
pub(crate) action_type: &'static str,
pub(crate) cancels: Vec<CancelWire>,
}
impl CancelAction {
pub(crate) fn new(asset: u32, order_id: u64) -> Self {
Self {
action_type: "cancel",
cancels: vec![CancelWire {
a: asset,
o: order_id,
}],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct LeverageAction {
#[serde(rename = "type")]
pub(crate) action_type: &'static str,
pub(crate) asset: u32,
#[serde(rename = "isCross")]
pub(crate) is_cross: bool,
pub(crate) leverage: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg(test)]
pub(crate) struct WithdrawAction {
#[serde(rename = "type")]
pub(crate) action_type: &'static str,
#[serde(rename = "hyperliquidChain")]
pub(crate) hyperliquid_chain: &'static str,
#[serde(rename = "signatureChainId")]
pub(crate) signature_chain_id: &'static str,
pub(crate) destination: String,
pub(crate) amount: String,
pub(crate) time: u64,
}
#[cfg(test)]
impl WithdrawAction {
pub(crate) fn new(
destination: &str,
amount: &str,
time: u64,
network: HyperliquidNetwork,
) -> Result<Self> {
let amount_number = crate::adapters::decimal::exact(amount)
.map_err(|err| Error::invalid_request("amount", format!("invalid decimal: {err}")))?;
if amount_number <= Decimal::ZERO {
return Err(Error::invalid_request(
"amount",
"must be greater than zero",
));
}
Ok(Self {
action_type: "withdraw3",
hyperliquid_chain: network.user_chain(),
signature_chain_id: "0x66eee",
destination: check_address(destination)?,
amount: amount.to_string(),
time,
})
}
}
impl LeverageAction {
pub(crate) fn new(asset: u32, is_cross: bool, leverage: u32) -> Self {
Self {
action_type: "updateLeverage",
asset,
is_cross,
leverage,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Signature {
pub(crate) r: String,
pub(crate) s: String,
pub(crate) v: u8,
}
pub(crate) fn signing_key(private_key: &str) -> Result<SigningKey> {
let text = private_key.trim();
let text = text.strip_prefix("0x").unwrap_or(text);
if text.len() != 64 || !text.chars().all(|character| character.is_ascii_hexdigit()) {
return Err(Error::auth(
"a Hyperliquid signing key is 32 bytes of hex, with or without a `0x` prefix",
));
}
let bytes =
hex::decode(text).map_err(|err| Error::auth(format!("unreadable signing key: {err}")))?;
SigningKey::from_slice(&bytes)
.map_err(|err| Error::auth(format!("not a valid secp256k1 key: {err}")))
}
#[cfg(test)]
pub(crate) fn address_of(key: &SigningKey) -> String {
let public = key.verifying_key().to_encoded_point(false);
let hash = Keccak256::digest(&public.as_bytes()[1..]);
format!("0x{}", hex::encode(&hash[12..]))
}
fn action_hash(action_bytes: &[u8], nonce: u64) -> [u8; 32] {
let mut data = Vec::with_capacity(action_bytes.len() + 9);
data.extend_from_slice(action_bytes);
data.extend_from_slice(&nonce.to_be_bytes());
data.push(0);
keccak(&data)
}
fn agent_digest(action_hash: [u8; 32], source: &str) -> [u8; 32] {
let mut agent = Vec::with_capacity(96);
agent.extend_from_slice(&keccak(b"Agent(string source,bytes32 connectionId)"));
agent.extend_from_slice(&keccak(source.as_bytes()));
agent.extend_from_slice(&action_hash);
let agent_hash = keccak(&agent);
let mut chain_id = [0u8; 32];
chain_id[24..].copy_from_slice(&AGENT_CHAIN_ID.to_be_bytes());
let mut domain = Vec::with_capacity(160);
domain.extend_from_slice(&keccak(
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
));
domain.extend_from_slice(&keccak(b"Exchange"));
domain.extend_from_slice(&keccak(b"1"));
domain.extend_from_slice(&chain_id);
domain.extend_from_slice(&[0u8; 32]);
let domain_separator = keccak(&domain);
let mut digest = Vec::with_capacity(66);
digest.extend_from_slice(b"\x19\x01");
digest.extend_from_slice(&domain_separator);
digest.extend_from_slice(&agent_hash);
keccak(&digest)
}
#[cfg(test)]
fn withdraw_digest(action: &WithdrawAction) -> [u8; 32] {
let mut message = Vec::with_capacity(160);
message.extend_from_slice(&keccak(
b"HyperliquidTransaction:Withdraw(string hyperliquidChain,string destination,string amount,uint64 time)",
));
message.extend_from_slice(&keccak(action.hyperliquid_chain.as_bytes()));
message.extend_from_slice(&keccak(action.destination.as_bytes()));
message.extend_from_slice(&keccak(action.amount.as_bytes()));
message.extend_from_slice(&word(action.time));
let message_hash = keccak(&message);
let mut domain = Vec::with_capacity(160);
domain.extend_from_slice(&keccak(
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
));
domain.extend_from_slice(&keccak(b"HyperliquidSignTransaction"));
domain.extend_from_slice(&keccak(b"1"));
domain.extend_from_slice(&word(USER_SIGNED_CHAIN_ID));
domain.extend_from_slice(&[0u8; 32]);
let domain_separator = keccak(&domain);
let mut digest = Vec::with_capacity(66);
digest.extend_from_slice(b"\x19\x01");
digest.extend_from_slice(&domain_separator);
digest.extend_from_slice(&message_hash);
keccak(&digest)
}
#[cfg(test)]
fn word(value: u64) -> [u8; 32] {
let mut encoded = [0u8; 32];
encoded[24..].copy_from_slice(&value.to_be_bytes());
encoded
}
fn keccak(input: &[u8]) -> [u8; 32] {
Keccak256::digest(input).into()
}
pub(crate) fn signed_body<A: Serialize>(
action: &A,
private_key: &str,
nonce: u64,
network: HyperliquidNetwork,
) -> Result<String> {
let action_bytes = rmp_serde::to_vec_named(action)
.map_err(|err| Error::decode(format!("could not encode hyperliquid action: {err}")))?;
let digest = agent_digest(action_hash(&action_bytes, nonce), network.agent_source());
let signature = sign(private_key, digest)?;
serde_json::to_string(&serde_json::json!({
"action": action,
"nonce": nonce,
"signature": signature,
}))
.map_err(|err| Error::decode(format!("could not build hyperliquid request body: {err}")))
}
#[cfg(test)]
pub(crate) fn sign_withdraw(private_key: &str, action: &WithdrawAction) -> Result<Signature> {
sign(private_key, withdraw_digest(action))
}
fn sign(private_key: &str, digest: [u8; 32]) -> Result<Signature> {
let key = signing_key(private_key)?;
let (signature, recovery) = key
.sign_prehash_recoverable(&digest)
.map_err(|err| Error::auth(format!("could not sign the request: {err}")))?;
Ok(Signature {
r: scalar_hex(&signature.r().to_bytes()[..]),
s: scalar_hex(&signature.s().to_bytes()[..]),
v: recovery.to_byte() + 27,
})
}
fn scalar_hex(bytes: &[u8]) -> String {
let encoded = hex::encode(bytes);
let trimmed = encoded.trim_start_matches('0');
if trimmed.is_empty() {
"0x0".to_string()
} else {
format!("0x{trimmed}")
}
}
pub(crate) fn check_address(address: &str) -> Result<String> {
let lowered = address.trim().to_ascii_lowercase();
let is_address = lowered.len() == 42
&& lowered.starts_with("0x")
&& lowered[2..].chars().all(|digit| digit.is_ascii_hexdigit());
if !is_address {
return Err(Error::auth(format!(
"`{address}` is not a 20-byte hex Hyperliquid account address"
)));
}
Ok(lowered)
}
#[cfg(test)]
pub(crate) fn recover(digest: [u8; 32], signature: &Signature) -> Result<String> {
use k256::ecdsa::{RecoveryId, VerifyingKey};
let scalar = |text: &str, field: &'static str| -> Result<[u8; 32]> {
let digits = text.strip_prefix("0x").unwrap_or(text);
let padded = format!("{digits:0>64}");
let bytes = hex::decode(&padded)
.map_err(|err| Error::decode(format!("`{field}` is not hex: {err}")))?;
let mut scalar = [0u8; 32];
scalar.copy_from_slice(&bytes);
Ok(scalar)
};
let mut serialized = [0u8; 64];
serialized[..32].copy_from_slice(&scalar(&signature.r, "r")?);
serialized[32..].copy_from_slice(&scalar(&signature.s, "s")?);
let parsed = k256::ecdsa::Signature::from_slice(&serialized)
.map_err(|err| Error::decode(format!("unreadable signature: {err}")))?;
let recovery = RecoveryId::from_byte(signature.v - 27)
.ok_or_else(|| Error::decode("signature recovery id is out of range"))?;
let key = VerifyingKey::recover_from_prehash(&digest, &parsed, recovery)
.map_err(|err| Error::decode(format!("could not recover the signer: {err}")))?;
let public = key.to_encoded_point(false);
Ok(format!(
"0x{}",
hex::encode(&Keccak256::digest(&public.as_bytes()[1..])[12..])
))
}
pub(crate) fn missing_query_address() -> Error {
Error::auth(format!(
"{EXCHANGE} account reads need a public query address; use `with_query_address` or `with_wallet`"
))
}
pub(crate) fn missing_signer() -> Error {
Error::auth(format!(
"{EXCHANGE} signed actions need a local signer; use `with_signer` or `with_wallet`"
))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
const TEST_ADDRESS: &str = "0x14791697260e4c9a71f18484c9f997b308e59325";
fn order() -> OrderAction {
OrderAction::new(OrderWire {
a: 0,
b: true,
p: "27123".to_string(),
s: "1.2345".to_string(),
r: false,
t: OrderKind {
limit: LimitKind { tif: "Gtc" },
},
})
}
#[test]
fn the_documented_key_derives_the_documented_address() {
let key = signing_key(TEST_KEY).expect("a documented key");
assert_eq!(address_of(&key), TEST_ADDRESS);
assert_eq!(
address_of(&signing_key(&TEST_KEY[2..]).expect("the same key")),
TEST_ADDRESS
);
}
fn published_order() -> OrderAction {
OrderAction::new(OrderWire {
a: 4,
b: true,
p: "1670.1".to_string(),
s: "0.0147".to_string(),
r: false,
t: OrderKind {
limit: LimitKind { tif: "Ioc" },
},
})
}
const PUBLISHED_NONCE: u64 = 1_677_777_606_040;
const PUBLISHED_ACTION_HASH: &str =
"0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908";
#[test]
fn the_published_action_hash_comes_out_byte_for_byte() {
let bytes = rmp_serde::to_vec_named(&published_order()).expect("an encodable action");
assert_eq!(
hex::encode(action_hash(&bytes, PUBLISHED_NONCE)),
PUBLISHED_ACTION_HASH
);
}
#[test]
fn the_signed_body_signs_the_nonce_it_sends() {
let mut published = [0u8; 32];
published.copy_from_slice(&hex::decode(PUBLISHED_ACTION_HASH).expect("published hex"));
for network in [HyperliquidNetwork::Mainnet, HyperliquidNetwork::Testnet] {
let body = signed_body(&published_order(), TEST_KEY, PUBLISHED_NONCE, network)
.expect("a signed body");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
let signature = Signature {
r: parsed["signature"]["r"].as_str().expect("an r").to_string(),
s: parsed["signature"]["s"].as_str().expect("an s").to_string(),
v: parsed["signature"]["v"].as_u64().expect("a v") as u8,
};
assert_eq!(parsed["nonce"], PUBLISHED_NONCE);
assert_eq!(
recover(agent_digest(published, network.agent_source()), &signature)
.expect("a signer"),
TEST_ADDRESS,
"{network:?}"
);
}
}
#[test]
fn the_published_signature_comes_out_scalar_for_scalar() {
let action = OrderAction::new(OrderWire {
a: 1,
b: true,
p: "100".to_string(),
s: "100".to_string(),
r: false,
t: OrderKind {
limit: LimitKind { tif: "Gtc" },
},
});
let signature = |network| -> serde_json::Value {
let body = signed_body(&action, TEST_KEY, 0, network).expect("a signed body");
serde_json::from_str::<serde_json::Value>(&body).expect("a JSON body")["signature"]
.clone()
};
let mainnet = signature(HyperliquidNetwork::Mainnet);
assert_eq!(
mainnet["r"],
"0xd65369825a9df5d80099e513cce430311d7d26ddf477f5b3a33d2806b100d78e"
);
assert_eq!(
mainnet["s"],
"0x2b54116ff64054968aa237c20ca9ff68000f977c93289157748a3162b6ea940e"
);
assert_eq!(mainnet["v"], 28);
let testnet = signature(HyperliquidNetwork::Testnet);
assert_eq!(
testnet["r"],
"0x82b2ba28e76b3d761093aaded1b1cdad4960b3af30212b343fb2e6cdfa4e3d54"
);
assert_eq!(
testnet["s"],
"0x6b53878fc99d26047f4d7e8c90eb98955a109f44209163f52d8dc4278cbbd9f5"
);
assert_eq!(testnet["v"], 27);
}
#[test]
fn the_official_user_signed_withdraw_vector_matches_exactly() {
let action = WithdrawAction::new(
"0x5e9ee1089755c3435139848e47e6635505d5a13a",
"1",
1_687_816_341_423,
HyperliquidNetwork::Testnet,
)
.expect("the official action");
let signature = sign_withdraw(TEST_KEY, &action).expect("the official signature");
assert_eq!(
signature.r,
"0x8363524c799e90ce9bc41022f7c39b4e9bdba786e5f9c72b20e43e1462c37cf9"
);
assert_eq!(
signature.s,
"0x58b1411a775938b83e29182e8ef74975f9054c8e97ebf5ec2dc8d51bfc893881"
);
assert_eq!(signature.v, 28);
assert_eq!(
recover(withdraw_digest(&action), &signature).expect("the signer"),
TEST_ADDRESS
);
let wire = serde_json::to_value(&action).expect("wire action");
assert_eq!(wire["type"], "withdraw3");
assert_eq!(wire["hyperliquidChain"], "Testnet");
assert_eq!(wire["signatureChainId"], "0x66eee");
}
#[test]
fn a_signature_recovers_to_the_wallet_that_made_it() {
let bytes = rmp_serde::to_vec_named(&order()).expect("an encodable action");
let digest = agent_digest(action_hash(&bytes, 1_700_000_000_000), "a");
let signature = sign(TEST_KEY, digest).expect("a signature");
assert_eq!(recover(digest, &signature).expect("a signer"), TEST_ADDRESS);
}
#[test]
fn mainnet_and_testnet_signatures_are_not_interchangeable() {
let bytes = rmp_serde::to_vec_named(&order()).expect("an encodable action");
let hash = action_hash(&bytes, 1_700_000_000_000);
let mainnet = agent_digest(hash, HyperliquidNetwork::Mainnet.agent_source());
let testnet = agent_digest(hash, HyperliquidNetwork::Testnet.agent_source());
assert_ne!(mainnet, testnet);
let signed_for_testnet = sign(TEST_KEY, testnet).expect("a signature");
assert_ne!(
recover(mainnet, &signed_for_testnet).expect("some signer"),
TEST_ADDRESS
);
}
#[test]
fn the_nonce_is_part_of_what_is_signed() {
let bytes = rmp_serde::to_vec_named(&order()).expect("an encodable action");
assert_ne!(action_hash(&bytes, 1), action_hash(&bytes, 2));
}
#[test]
fn an_action_is_msgpacked_as_named_keys_in_declaration_order() {
let bytes = rmp_serde::to_vec_named(&order()).expect("an encodable action");
let readable: serde_json::Value =
rmp_serde::from_slice(&bytes).expect("a map, not an array");
assert_eq!(readable["type"], "order");
assert_eq!(readable["grouping"], "na");
assert_eq!(readable["orders"][0]["a"], 0);
assert_eq!(readable["orders"][0]["b"], true);
assert_eq!(readable["orders"][0]["p"], "27123");
assert_eq!(readable["orders"][0]["s"], "1.2345");
assert_eq!(readable["orders"][0]["r"], false);
assert_eq!(readable["orders"][0]["t"]["limit"]["tif"], "Gtc");
assert_eq!(bytes[0], 0x83);
}
#[test]
fn the_signed_body_carries_the_action_the_signature_covers() {
let body = signed_body(
&order(),
TEST_KEY,
1_700_000_000_000,
HyperliquidNetwork::Mainnet,
)
.expect("a signed body");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
assert_eq!(parsed["nonce"], 1_700_000_000_000_u64);
assert_eq!(parsed["action"]["type"], "order");
assert_eq!(parsed["action"]["orders"][0]["p"], "27123");
assert!(matches!(parsed["signature"]["v"].as_u64(), Some(27 | 28)));
assert!(
parsed["signature"]["r"]
.as_str()
.is_some_and(|r| r.starts_with("0x"))
);
assert!(!body.contains(TEST_KEY));
assert!(!body.contains(&TEST_KEY[2..]));
}
#[test]
fn signing_is_deterministic_so_a_retry_sends_the_identical_bytes() {
let first =
signed_body(&order(), TEST_KEY, 42, HyperliquidNetwork::Mainnet).expect("a body");
let second =
signed_body(&order(), TEST_KEY, 42, HyperliquidNetwork::Mainnet).expect("a body");
assert_eq!(first, second);
}
#[test]
fn cancel_and_leverage_actions_use_hyperliquids_own_field_names() {
let cancel: serde_json::Value =
serde_json::to_value(CancelAction::new(3, 91_490_942)).expect("serializable");
let leverage: serde_json::Value =
serde_json::to_value(LeverageAction::new(3, false, 20)).expect("serializable");
assert_eq!(cancel["type"], "cancel");
assert_eq!(cancel["cancels"][0]["a"], 3);
assert_eq!(cancel["cancels"][0]["o"], 91_490_942_u64);
assert_eq!(leverage["type"], "updateLeverage");
assert_eq!(leverage["isCross"], false);
assert_eq!(leverage["leverage"], 20);
}
#[test]
fn a_key_that_is_not_32_bytes_of_hex_is_refused_before_anything_is_signed() {
for bad in [
"",
"0x",
"not-hex",
&TEST_KEY[..40],
&format!("{TEST_KEY}00"),
] {
assert!(matches!(signing_key(bad), Err(Error::Auth { .. })), "{bad}");
}
}
#[test]
fn query_addresses_and_signers_are_validated_independently() {
assert_eq!(
check_address(TEST_ADDRESS).expect("an address"),
TEST_ADDRESS
);
assert!(signing_key(TEST_KEY).is_ok());
assert!(matches!(check_address("0xabc"), Err(Error::Auth { .. })));
assert!(matches!(signing_key("not-a-key"), Err(Error::Auth { .. })));
}
#[test]
fn a_withdraw_signature_rejects_an_invalid_destination_or_amount() {
for amount in ["0", "-1", "not-a-number"] {
assert!(matches!(
WithdrawAction::new(
TEST_ADDRESS,
amount,
1_687_816_341_423,
HyperliquidNetwork::Mainnet
),
Err(Error::InvalidRequest { .. })
));
}
assert!(matches!(
WithdrawAction::new("0xabc", "1", 1_687_816_341_423, HyperliquidNetwork::Mainnet),
Err(Error::Auth { .. })
));
}
}