use alloy::primitives::Address;
use alloy::signers::local::PrivateKeySigner;
use dashmap::DashMap;
use parking_lot::RwLock;
use reqwest::Client;
use rust_decimal::Decimal;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::error::{Error, Result};
use crate::order::{Order, PlacedOrder, TriggerOrder};
use crate::signing::sign_hash;
use crate::types::*;
const DEFAULT_WORKER_URL: &str = "https://send.hyperliquidapi.com";
const DEFAULT_WORKER_INFO_URL: &str = "https://send.hyperliquidapi.com/info";
const KNOWN_PATHS: &[&str] = &["info", "hypercore", "evm", "nanoreth", "ws", "send"];
const HL_INFO_URL: &str = "https://api.hyperliquid.xyz/info";
#[allow(dead_code)]
const HL_EXCHANGE_URL: &str = "https://api.hyperliquid.xyz/exchange";
const DEFAULT_SLIPPAGE: f64 = 0.03; const DEFAULT_TIMEOUT_SECS: u64 = 30;
const METADATA_CACHE_TTL_SECS: u64 = 300; const HYPE_WEI_DECIMALS: u32 = 8;
const QN_SUPPORTED_INFO_TYPES: &[&str] = &[
"meta",
"spotMeta",
"clearinghouseState",
"spotClearinghouseState",
"openOrders",
"exchangeStatus",
"frontendOpenOrders",
"liquidatable",
"activeAssetData",
"maxMarketOrderNtls",
"vaultSummaries",
"userVaultEquities",
"leadingVaults",
"extraAgents",
"subAccounts",
"userFees",
"userRateLimit",
"spotDeployState",
"perpDeployAuctionStatus",
"delegations",
"delegatorSummary",
"maxBuilderFee",
"userToMultiSigSigners",
"userRole",
"perpsAtOpenInterestCap",
"validatorL1Votes",
"marginTable",
"perpDexs",
"webData2",
"outcomeMeta",
];
fn parse_outcome_description(description: &str) -> HashMap<String, String> {
description
.split('|')
.filter_map(|part| {
let (key, value) = part.split_once(':')?;
Some((key.to_string(), value.to_string()))
})
.collect()
}
fn format_prediction_expiry(expiry: &str) -> String {
if expiry.len() != 13 || expiry.as_bytes().get(8) != Some(&b'-') {
return expiry.to_string();
}
format!(
"{}-{}-{}T{}:{}:00Z",
&expiry[0..4],
&expiry[4..6],
&expiry[6..8],
&expiry[9..11],
&expiry[11..13]
)
}
fn prediction_title(fields: &HashMap<String, String>) -> String {
let underlying = fields.get("underlying").map(String::as_str).unwrap_or("Outcome");
match (fields.get("targetPrice"), fields.get("expiry")) {
(Some(target_price), Some(expiry)) => {
format!("{} above {} on {}", underlying, target_price, format_prediction_expiry(expiry))
}
(Some(target_price), None) => format!("{} above {}", underlying, target_price),
_ => underlying.to_string(),
}
}
fn prediction_slug(value: &str) -> String {
let mut out = String::new();
let mut last_dash = false;
for ch in value.to_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch);
last_dash = false;
} else if !last_dash && !out.is_empty() {
out.push('-');
last_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn app_style_prediction_slug(fields: &HashMap<String, String>, side: Option<&str>) -> Option<String> {
let underlying = fields.get("underlying")?;
let target_price = fields.get("targetPrice")?;
let expiry = fields.get("expiry")?;
if expiry.len() != 13 {
return None;
}
let month = match &expiry[4..6] {
"01" => "jan",
"02" => "feb",
"03" => "mar",
"04" => "apr",
"05" => "may",
"06" => "jun",
"07" => "jul",
"08" => "aug",
"09" => "sep",
"10" => "oct",
"11" => "nov",
"12" => "dec",
_ => return None,
};
let mut parts = vec![underlying.as_str(), "above", target_price.as_str()];
if let Some(side) = side {
parts.push(side);
}
parts.extend([month, &expiry[6..8], &expiry[9..13]]);
Some(prediction_slug(&parts.join("-")))
}
fn is_prediction_asset(asset: &str) -> bool {
if let Some(rest) = asset.strip_prefix('#').or_else(|| asset.strip_prefix('+')) {
return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
}
asset.parse::<usize>().map(|id| id >= 100_000_000).unwrap_or(false)
}
fn prediction_symbol(asset: &str) -> String {
if let Some(rest) = asset.strip_prefix('+') {
return format!("#{}", rest);
}
if let Ok(id) = asset.parse::<usize>() {
if id >= 100_000_000 {
return format!("#{}", id - 100_000_000);
}
}
asset.to_string()
}
fn prediction_asset_id(asset: &str) -> Option<usize> {
let rest = asset.strip_prefix('#').or_else(|| asset.strip_prefix('+'))?;
Some(100_000_000 + rest.parse::<usize>().ok()?)
}
#[derive(Debug, Clone)]
pub struct AssetInfo {
pub index: usize,
pub name: String,
pub sz_decimals: u8,
pub is_spot: bool,
}
#[derive(Debug, Default)]
pub struct MetadataCache {
assets: RwLock<HashMap<String, AssetInfo>>,
assets_by_index: RwLock<HashMap<usize, AssetInfo>>,
dexes: RwLock<Vec<String>>,
last_update: RwLock<Option<SystemTime>>,
}
impl MetadataCache {
pub fn get_asset(&self, name: &str) -> Option<AssetInfo> {
self.assets.read().get(name).cloned()
}
pub fn get_asset_by_index(&self, index: usize) -> Option<AssetInfo> {
self.assets_by_index.read().get(&index).cloned()
}
pub fn resolve_asset(&self, name: &str) -> Option<usize> {
self.assets.read().get(name).map(|a| a.index)
}
pub fn get_dexes(&self) -> Vec<String> {
self.dexes.read().clone()
}
pub fn is_valid(&self) -> bool {
if let Some(last) = *self.last_update.read() {
if let Ok(elapsed) = last.elapsed() {
return elapsed.as_secs() < METADATA_CACHE_TTL_SECS;
}
}
false
}
pub fn update(&self, meta: &Value, spot_meta: Option<&Value>, dexes: &[String]) {
let mut assets = HashMap::new();
let mut assets_by_index = HashMap::new();
if let Some(universe) = meta.get("universe").and_then(|u| u.as_array()) {
for (i, asset) in universe.iter().enumerate() {
if let Some(name) = asset.get("name").and_then(|n| n.as_str()) {
let sz_decimals = asset
.get("szDecimals")
.and_then(|d| d.as_u64())
.unwrap_or(8) as u8;
let info = AssetInfo {
index: i,
name: name.to_string(),
sz_decimals,
is_spot: false,
};
assets.insert(name.to_string(), info.clone());
assets_by_index.insert(i, info);
}
}
}
if let Some(spot) = spot_meta {
if let Some(tokens) = spot.get("tokens").and_then(|t| t.as_array()) {
for token in tokens {
if let (Some(name), Some(index)) = (
token.get("name").and_then(|n| n.as_str()),
token.get("index").and_then(|i| i.as_u64()),
) {
let sz_decimals = token
.get("szDecimals")
.and_then(|d| d.as_u64())
.unwrap_or(8) as u8;
let info = AssetInfo {
index: index as usize,
name: name.to_string(),
sz_decimals,
is_spot: true,
};
assets.insert(name.to_string(), info.clone());
assets_by_index.insert(index as usize, info);
}
}
}
}
*self.assets.write() = assets;
*self.assets_by_index.write() = assets_by_index;
*self.dexes.write() = dexes.to_vec();
*self.last_update.write() = Some(SystemTime::now());
}
pub fn update_outcomes(&self, outcome_meta: &Value) {
let mut assets = self.assets.write();
let mut assets_by_index = self.assets_by_index.write();
if let Some(outcomes) = outcome_meta.get("outcomes").and_then(|o| o.as_array()) {
for outcome in outcomes {
let Some(outcome_id) = outcome.get("outcome").and_then(|o| o.as_u64()) else {
continue;
};
let side_count = outcome
.get("sideSpecs")
.and_then(|s| s.as_array())
.map(Vec::len)
.unwrap_or(0);
for side_index in 0..side_count {
let encoding = outcome_id as usize * 10 + side_index;
let name = format!("#{}", encoding);
let info = AssetInfo {
index: 100_000_000 + encoding,
name: name.clone(),
sz_decimals: 0,
is_spot: false,
};
assets.insert(name, info.clone());
assets_by_index.insert(info.index, info);
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct EndpointInfo {
pub base: String,
pub token: Option<String>,
pub is_quicknode: bool,
}
impl EndpointInfo {
pub fn parse(url: &str) -> Self {
let parsed = url::Url::parse(url).ok();
if let Some(parsed) = parsed {
let base = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
let is_quicknode = parsed.host_str().map(|h| h.contains("quiknode.pro")).unwrap_or(false);
let path_parts: Vec<&str> = parsed.path()
.trim_matches('/')
.split('/')
.filter(|p| !p.is_empty())
.collect();
let token = path_parts.iter()
.find(|&part| !KNOWN_PATHS.contains(part))
.map(|s| s.to_string());
Self { base, token, is_quicknode }
} else {
Self {
base: url.to_string(),
token: None,
is_quicknode: url.contains("quiknode.pro"),
}
}
}
pub fn build_url(&self, suffix: &str) -> String {
if let Some(ref token) = self.token {
format!("{}/{}/{}", self.base, token, suffix)
} else {
format!("{}/{}", self.base, suffix)
}
}
pub fn build_ws_url(&self) -> String {
let ws_base = self.base.replace("https://", "wss://").replace("http://", "ws://");
if let Some(ref token) = self.token {
format!("{}/{}/hypercore/ws", ws_base, token)
} else {
format!("{}/ws", ws_base)
}
}
pub fn build_grpc_url(&self) -> String {
if let Some(ref token) = self.token {
let grpc_base = self.base.replace(":443", "").replace("https://", "");
format!("https://{}:10000/{}", grpc_base, token)
} else {
self.base.replace(":443", ":10000")
}
}
}
pub struct HyperliquidSDKInner {
pub(crate) http_client: Client,
pub(crate) signer: Option<PrivateKeySigner>,
pub(crate) address: Option<Address>,
pub(crate) chain: Chain,
pub(crate) endpoint: Option<String>,
pub(crate) endpoint_info: Option<EndpointInfo>,
pub(crate) slippage: f64,
pub(crate) metadata: MetadataCache,
pub(crate) mid_prices: DashMap<String, f64>,
}
impl std::fmt::Debug for HyperliquidSDKInner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HyperliquidSDKInner")
.field("address", &self.address)
.field("chain", &self.chain)
.field("endpoint", &self.endpoint)
.field("slippage", &self.slippage)
.finish_non_exhaustive()
}
}
const DEFAULT_EXCHANGE_URL: &str = "https://send.hyperliquidapi.com/exchange";
impl HyperliquidSDKInner {
fn exchange_url(&self) -> String {
DEFAULT_EXCHANGE_URL.to_string()
}
fn info_url(&self, query_type: &str) -> String {
if let Some(ref info) = self.endpoint_info {
if info.is_quicknode && QN_SUPPORTED_INFO_TYPES.contains(&query_type) {
return info.build_url("info");
}
}
DEFAULT_WORKER_INFO_URL.to_string()
}
pub fn hypercore_url(&self) -> String {
if let Some(ref info) = self.endpoint_info {
if info.is_quicknode {
return info.build_url("hypercore");
}
}
HL_INFO_URL.to_string()
}
pub fn evm_url(&self, use_nanoreth: bool) -> String {
if let Some(ref info) = self.endpoint_info {
if info.is_quicknode {
let suffix = if use_nanoreth { "nanoreth" } else { "evm" };
return info.build_url(suffix);
}
}
match self.chain {
Chain::Mainnet => "https://rpc.hyperliquid.xyz/evm".to_string(),
Chain::Testnet => "https://rpc.hyperliquid-testnet.xyz/evm".to_string(),
}
}
pub fn ws_url(&self) -> String {
if let Some(ref info) = self.endpoint_info {
return info.build_ws_url();
}
"wss://api.hyperliquid.xyz/ws".to_string()
}
pub fn grpc_url(&self) -> String {
if let Some(ref info) = self.endpoint_info {
if info.is_quicknode {
return info.build_grpc_url();
}
}
String::new()
}
pub async fn query_info(&self, body: &Value) -> Result<Value> {
let query_type = body.get("type").and_then(|t| t.as_str()).unwrap_or("");
let url = self.info_url(query_type);
let response = self
.http_client
.post(&url)
.json(body)
.send()
.await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(Error::NetworkError(format!(
"Info endpoint returned {}: {}",
status, text
)));
}
serde_json::from_str(&text).map_err(|e| Error::JsonError(e.to_string()))
}
pub async fn build_action(&self, action: &Value, slippage: Option<f64>) -> Result<BuildResponse> {
self.build_action_with_priority(action, slippage, None).await
}
pub async fn build_action_with_priority(
&self,
action: &Value,
slippage: Option<f64>,
priority_fee: Option<u64>,
) -> Result<BuildResponse> {
let url = self.exchange_url();
let mut body = json!({ "action": action });
if let Some(priority_fee) = priority_fee {
body["priorityFee"] = json!(priority_fee);
}
if let Some(s) = slippage {
if !s.is_finite() || s <= 0.0 {
return Err(Error::ValidationError(
"Slippage must be a positive finite number".to_string(),
));
}
body["slippage"] = json!(s);
}
let response = self
.http_client
.post(url)
.json(&body)
.send()
.await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(Error::NetworkError(format!(
"Build request failed {}: {}",
status, text
)));
}
let result: Value = serde_json::from_str(&text)?;
if let Some(error) = result.get("error") {
return Err(Error::from_api_error(
error.as_str().unwrap_or("Unknown error"),
));
}
Ok(BuildResponse {
hash: result
.get("hash")
.and_then(|h| h.as_str())
.unwrap_or("")
.to_string(),
nonce: result.get("nonce").and_then(|n| n.as_u64()).unwrap_or(0),
action: result.get("action").cloned().unwrap_or(action.clone()),
})
}
pub async fn send_action(
&self,
action: &Value,
nonce: u64,
signature: &Signature,
) -> Result<Value> {
let url = self.exchange_url();
let body = json!({
"action": action,
"nonce": nonce,
"signature": signature,
});
let response = self
.http_client
.post(url)
.json(&body)
.send()
.await?;
let status = response.status();
let text = response.text().await?;
if !status.is_success() {
return Err(Error::NetworkError(format!(
"Send request failed {}: {}",
status, text
)));
}
let result: Value = serde_json::from_str(&text)?;
if let Some(hl_status) = result.get("status") {
if hl_status.as_str() == Some("err") {
if let Some(response) = result.get("response") {
let raw = response.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| response.to_string());
return Err(Error::from_api_error(&raw));
}
}
}
Ok(result)
}
pub async fn build_sign_send(&self, action: &Value, slippage: Option<f64>) -> Result<Value> {
self.build_sign_send_with_priority(action, slippage, None).await
}
pub async fn build_sign_send_with_priority(
&self,
action: &Value,
slippage: Option<f64>,
priority_fee: Option<u64>,
) -> Result<Value> {
let signer = self
.signer
.as_ref()
.ok_or_else(|| Error::ConfigError("No private key configured".to_string()))?;
let effective_slippage = slippage.or_else(|| {
if self.slippage > 0.0 {
Some(self.slippage)
} else {
None
}
});
let build_result = self
.build_action_with_priority(action, effective_slippage, priority_fee)
.await?;
let hash_bytes = hex::decode(build_result.hash.trim_start_matches("0x"))
.map_err(|e| Error::SigningError(format!("Invalid hash: {}", e)))?;
let hash = alloy::primitives::B256::from_slice(&hash_bytes);
let signature = sign_hash(signer, hash).await?;
self.send_action(&build_result.action, build_result.nonce, &signature)
.await
}
pub async fn refresh_metadata(&self) -> Result<()> {
let meta = self.query_info(&json!({"type": "meta"})).await?;
let spot_meta = self.query_info(&json!({"type": "spotMeta"})).await.ok();
let dexes_result = self.query_info(&json!({"type": "perpDexs"})).await.ok();
let dexes: Vec<String> = dexes_result
.and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|d| d.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
.collect()
})
})
.unwrap_or_default();
self.metadata.update(&meta, spot_meta.as_ref(), &dexes);
if let Ok(outcome_meta) = self.query_info(&json!({"type": "outcomeMeta"})).await {
self.metadata.update_outcomes(&outcome_meta);
}
Ok(())
}
pub async fn fetch_all_mids(&self) -> Result<HashMap<String, f64>> {
let result = self.query_info(&json!({"type": "allMids"})).await?;
let mut mids = HashMap::new();
if let Some(obj) = result.as_object() {
for (coin, price_val) in obj {
let price_str = price_val.as_str().unwrap_or("");
if let Ok(price) = price_str.parse::<f64>() {
mids.insert(coin.clone(), price);
self.mid_prices.insert(coin.clone(), price);
}
}
}
for dex in self.metadata.get_dexes() {
if let Ok(dex_result) = self.query_info(&json!({"type": "allMids", "dex": dex})).await {
if let Some(obj) = dex_result.as_object() {
for (coin, price_val) in obj {
let price_str = price_val.as_str().unwrap_or("");
if let Ok(price) = price_str.parse::<f64>() {
mids.insert(coin.clone(), price);
self.mid_prices.insert(coin.clone(), price);
}
}
}
}
}
Ok(mids)
}
pub async fn get_mid_price(&self, asset: &str) -> Result<f64> {
let asset = prediction_symbol(asset);
if let Some(price) = self.mid_prices.get(&asset) {
return Ok(*price);
}
let mids = self.fetch_all_mids().await?;
mids.get(&asset)
.copied()
.ok_or_else(|| Error::ValidationError(format!("No price found for {}", asset)))
}
pub fn resolve_asset(&self, name: &str) -> Option<usize> {
if let Some(id) = prediction_asset_id(name) {
return Some(id);
}
if let Ok(id) = name.parse::<usize>() {
return Some(id);
}
self.metadata.resolve_asset(name)
}
pub async fn cancel_by_oid(&self, oid: u64, asset: &str) -> Result<Value> {
let asset_index = self
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let action = json!({
"type": "cancel",
"cancels": [{
"a": asset_index,
"o": oid,
}]
});
self.build_sign_send(&action, None).await
}
pub async fn modify_by_oid(
&self,
oid: u64,
asset: &str,
side: Side,
price: Decimal,
size: Decimal,
) -> Result<PlacedOrder> {
let asset_index = self
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let action = json!({
"type": "batchModify",
"modifies": [{
"oid": oid,
"order": {
"a": asset_index,
"b": side.is_buy(),
"p": price.normalize().to_string(),
"s": size.normalize().to_string(),
"r": false,
"t": {"limit": {"tif": "Gtc"}},
"c": "0x00000000000000000000000000000000",
}
}]
});
let response = self.build_sign_send(&action, None).await?;
Ok(PlacedOrder::from_response(
response,
asset.to_string(),
side,
size,
Some(price),
None,
))
}
}
#[derive(Debug)]
pub struct BuildResponse {
pub hash: String,
pub nonce: u64,
pub action: Value,
}
fn hype_to_wei(amount_hype: f64) -> Result<u64> {
if !amount_hype.is_finite() || amount_hype <= 0.0 {
return Err(Error::ValidationError(
"HYPE amount must be positive".to_string(),
));
}
let wei = decimal_amount_to_wei(&amount_hype.to_string())?;
if wei == 0 {
return Err(Error::ValidationError(
"HYPE amount is too small; minimum unit is 0.00000001 HYPE".to_string(),
));
}
Ok(wei)
}
fn decimal_amount_to_wei(raw: &str) -> Result<u64> {
let amount = raw.trim();
let parts: Vec<&str> = amount.split('.').collect();
if amount.is_empty() || parts.len() > 2 || !all_decimal_digits(parts[0]) {
return Err(Error::ValidationError(
"HYPE amount must be positive".to_string(),
));
}
let mut frac = String::new();
if parts.len() == 2 {
if !all_decimal_digits(parts[1]) {
return Err(Error::ValidationError(
"HYPE amount must be positive".to_string(),
));
}
frac.push_str(parts[1]);
}
let decimals = HYPE_WEI_DECIMALS as usize;
if frac.len() > decimals {
frac.truncate(decimals);
}
while frac.len() < decimals {
frac.push('0');
}
let whole_wei = parts[0]
.parse::<u64>()
.map_err(|_| Error::ValidationError("HYPE amount is too large".to_string()))?;
let frac_wei = frac
.parse::<u64>()
.map_err(|_| Error::ValidationError("HYPE amount must be positive".to_string()))?;
let scale = 10u64.pow(HYPE_WEI_DECIMALS);
whole_wei
.checked_mul(scale)
.and_then(|wei| wei.checked_add(frac_wei))
.ok_or_else(|| Error::ValidationError("HYPE amount is too large".to_string()))
}
fn all_decimal_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
#[cfg(test)]
mod client_tests {
use super::*;
#[test]
fn hype_to_wei_uses_decimal_string_arithmetic() {
assert_eq!(hype_to_wei(0.001).unwrap(), 100_000);
assert_eq!(hype_to_wei(0.58).unwrap(), 58_000_000);
assert_eq!(hype_to_wei(0.00000001).unwrap(), 1);
assert_eq!(hype_to_wei(1.234567891).unwrap(), 123_456_789);
assert_eq!(hype_to_wei(1.0).unwrap(), 100_000_000);
assert!(hype_to_wei(0.000000001).is_err());
}
}
#[derive(Default)]
pub struct HyperliquidSDKBuilder {
endpoint: Option<String>,
private_key: Option<String>,
testnet: bool,
auto_approve: bool,
max_fee: String,
slippage: f64,
timeout: Duration,
}
impl HyperliquidSDKBuilder {
pub fn new() -> Self {
Self {
endpoint: None,
private_key: None,
testnet: false,
auto_approve: true,
max_fee: "1%".to_string(),
slippage: DEFAULT_SLIPPAGE,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
}
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub fn private_key(mut self, key: impl Into<String>) -> Self {
self.private_key = Some(key.into());
self
}
pub fn testnet(mut self, testnet: bool) -> Self {
self.testnet = testnet;
self
}
pub fn auto_approve(mut self, auto: bool) -> Self {
self.auto_approve = auto;
self
}
pub fn max_fee(mut self, fee: impl Into<String>) -> Self {
self.max_fee = fee.into();
self
}
pub fn slippage(mut self, slippage: f64) -> Self {
self.slippage = slippage;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub async fn build(self) -> Result<HyperliquidSDK> {
let private_key = self
.private_key
.or_else(|| std::env::var("PRIVATE_KEY").ok());
let (signer, address) = if let Some(key) = private_key {
let key = key.trim_start_matches("0x");
let signer = PrivateKeySigner::from_str(key)?;
let address = signer.address();
(Some(signer), Some(address))
} else {
(None, None)
};
let http_client = Client::builder()
.timeout(self.timeout)
.build()
.map_err(|e| Error::ConfigError(format!("Failed to create HTTP client: {}", e)))?;
let chain = if self.testnet {
Chain::Testnet
} else {
Chain::Mainnet
};
let endpoint_info = self.endpoint.as_ref().map(|ep| EndpointInfo::parse(ep));
let inner = Arc::new(HyperliquidSDKInner {
http_client,
signer,
address,
chain,
endpoint: self.endpoint,
endpoint_info,
slippage: self.slippage,
metadata: MetadataCache::default(),
mid_prices: DashMap::new(),
});
if let Err(e) = inner.refresh_metadata().await {
tracing::warn!("Failed to fetch initial metadata: {}", e);
}
Ok(HyperliquidSDK {
inner,
auto_approve: self.auto_approve,
max_fee: self.max_fee,
})
}
}
pub struct HyperliquidSDK {
inner: Arc<HyperliquidSDKInner>,
#[allow(dead_code)]
auto_approve: bool,
max_fee: String,
}
impl HyperliquidSDK {
pub fn new() -> HyperliquidSDKBuilder {
HyperliquidSDKBuilder::new()
}
pub fn address(&self) -> Option<Address> {
self.inner.address
}
pub fn chain(&self) -> Chain {
self.inner.chain
}
pub fn info(&self) -> crate::info::Info {
crate::info::Info::new(self.inner.clone())
}
pub fn core(&self) -> crate::hypercore::HyperCore {
crate::hypercore::HyperCore::new(self.inner.clone())
}
pub fn evm(&self) -> crate::evm::EVM {
crate::evm::EVM::new(self.inner.clone())
}
pub fn stream(&self) -> crate::stream::Stream {
crate::stream::Stream::new(self.inner.endpoint.clone())
}
pub fn grpc(&self) -> crate::grpc::GRPCStream {
crate::grpc::GRPCStream::new(self.inner.endpoint.clone())
}
pub fn evm_stream(&self) -> crate::evm_stream::EVMStream {
crate::evm_stream::EVMStream::new(self.inner.endpoint.clone())
}
pub async fn markets(&self) -> Result<Value> {
self.inner.query_info(&json!({"type": "meta"})).await
}
pub async fn prediction_markets(&self) -> Result<Vec<PredictionMarket>> {
let outcome_meta = self.inner.query_info(&json!({"type": "outcomeMeta"})).await?;
let mids = self.inner.fetch_all_mids().await?;
let mut markets = Vec::new();
let Some(outcomes) = outcome_meta.get("outcomes").and_then(|o| o.as_array()) else {
return Ok(markets);
};
for outcome in outcomes {
let Some(outcome_id) = outcome.get("outcome").and_then(|o| o.as_u64()) else {
continue;
};
let description = outcome
.get("description")
.and_then(|d| d.as_str())
.unwrap_or_default()
.to_string();
let fields = parse_outcome_description(&description);
let title = prediction_title(&fields);
let mut sides = Vec::new();
if let Some(side_specs) = outcome.get("sideSpecs").and_then(|s| s.as_array()) {
for (side_index, side_spec) in side_specs.iter().enumerate() {
let encoding = outcome_id as usize * 10 + side_index;
let symbol = format!("#{}", encoding);
sides.push(PredictionSide {
outcome: outcome_id,
side: side_index,
name: side_spec
.get("name")
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string(),
symbol: symbol.clone(),
token: format!("+{}", encoding),
asset_id: 100_000_000 + encoding,
mid: mids.get(&symbol).map(|m| m.to_string()),
sz_decimals: 0,
supports_priority_fee: false,
});
}
}
if sides.len() < 2 {
continue;
}
let slug = app_style_prediction_slug(&fields, None)
.unwrap_or_else(|| prediction_slug(&title));
let mut aliases = vec![prediction_slug(&title)];
for side in sides.iter().take(2) {
if let Some(alias) = app_style_prediction_slug(&fields, Some(&side.name)) {
aliases.push(alias);
}
}
markets.push(PredictionMarket {
outcome: outcome_id,
name: outcome
.get("name")
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string(),
description,
title: title.clone(),
slug,
underlying: fields.get("underlying").cloned(),
target_price: fields.get("targetPrice").cloned(),
expiry: fields.get("expiry").map(|e| format_prediction_expiry(e)),
period: fields.get("period").cloned(),
collateral: "USDH".to_string(),
min_order_value: "10".to_string(),
aliases,
yes: sides[0].clone(),
no: sides[1].clone(),
sides,
});
}
Ok(markets)
}
pub async fn predictions(&self) -> Result<Vec<PredictionMarket>> {
self.prediction_markets().await
}
pub async fn prediction_market(&self, filter: PredictionMarketFilter) -> Result<PredictionMarket> {
let markets = self.prediction_markets().await?;
markets
.into_iter()
.find(|market| {
if let Some(query) = &filter.query {
if !market.matches(query) {
return false;
}
}
if let Some(underlying) = &filter.underlying {
if market.underlying.as_deref().unwrap_or_default().to_lowercase() != underlying.to_lowercase() {
return false;
}
}
if let Some(target_price) = &filter.target_price {
if market.target_price.as_deref() != Some(target_price.as_str()) {
return false;
}
}
if let Some(expiry) = &filter.expiry {
let formatted = format_prediction_expiry(expiry);
if market.expiry.as_deref() != Some(expiry.as_str())
&& market.expiry.as_deref() != Some(formatted.as_str())
{
return false;
}
}
true
})
.ok_or_else(|| Error::ValidationError(
"No matching prediction market found. Call sdk.prediction_markets() to list active HIP-4 markets.".to_string(),
))
}
pub async fn prediction_sides(&self) -> Result<Vec<PredictionSide>> {
Ok(self
.prediction_markets()
.await?
.into_iter()
.flat_map(|market| market.sides)
.collect())
}
pub async fn dexes(&self) -> Result<Value> {
self.inner.query_info(&json!({"type": "perpDexs"})).await
}
pub async fn open_orders(&self) -> Result<Value> {
let address = self
.inner
.address
.ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
self.inner
.query_info(&json!({
"type": "openOrders",
"user": format!("{:?}", address),
}))
.await
}
pub async fn order_status(&self, oid: u64, dex: Option<&str>) -> Result<Value> {
let address = self
.inner
.address
.ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
let mut req = json!({
"type": "orderStatus",
"user": format!("{:?}", address),
"oid": oid,
});
if let Some(d) = dex {
req["dex"] = json!(d);
}
self.inner.query_info(&req).await
}
pub async fn market_buy(&self, asset: impl Into<String>) -> MarketOrderBuilder {
MarketOrderBuilder::new(self.inner.clone(), asset.into(), Side::Buy)
}
pub async fn market_sell(&self, asset: impl Into<String>) -> MarketOrderBuilder {
MarketOrderBuilder::new(self.inner.clone(), asset.into(), Side::Sell)
}
pub async fn buy(
&self,
asset: impl Into<String>,
size: f64,
price: f64,
tif: TIF,
) -> Result<PlacedOrder> {
let asset = asset.into();
self.place_order(&asset, Side::Buy, size, Some(price), tif, false, false, false, None, None)
.await
}
pub async fn sell(
&self,
asset: impl Into<String>,
size: f64,
price: f64,
tif: TIF,
) -> Result<PlacedOrder> {
let asset = asset.into();
self.place_order(&asset, Side::Sell, size, Some(price), tif, false, false, false, None, None)
.await
}
pub async fn order(&self, order: Order) -> Result<PlacedOrder> {
order.validate()?;
let asset = order.get_asset();
let side = order.get_side();
let tif = order.get_tif();
let size = if let Some(s) = order.get_size() {
s
} else if let Some(notional) = order.get_notional() {
let mid = self.inner.get_mid_price(asset).await?;
Decimal::from_f64_retain(notional.to_string().parse::<f64>().unwrap_or(0.0) / mid)
.unwrap_or_default()
} else {
return Err(Error::ValidationError(
"Order must have size or notional".to_string(),
));
};
let is_market = order.is_market();
let price = if is_market {
None } else {
order
.get_price()
.map(|p| p.to_string().parse::<f64>().unwrap_or(0.0))
};
self.place_order(
asset,
side,
size.to_string().parse::<f64>().unwrap_or(0.0),
price,
if is_market { TIF::Market } else { tif },
order.is_reduce_only(),
is_market,
order.get_notional().is_some() && order.get_size().is_none(),
None, order.get_priority_fee(),
)
.await
}
pub async fn trigger_order(&self, order: TriggerOrder) -> Result<PlacedOrder> {
order.validate()?;
let asset = order.get_asset();
let asset_index = self
.inner
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let sz_decimals = self.inner.metadata.get_asset(asset)
.map(|a| a.sz_decimals)
.unwrap_or(5) as u32;
let trigger_px = order
.get_trigger_price()
.ok_or_else(|| Error::ValidationError("Trigger price required".to_string()))?;
let size = order
.get_size()
.ok_or_else(|| Error::ValidationError("Size required".to_string()))?;
let size_rounded = size.round_dp(sz_decimals);
let limit_px = if order.is_market() {
let mid = self.inner.get_mid_price(asset).await?;
let slippage = self.inner.slippage;
let price = if order.get_side().is_buy() {
mid * (1.0 + slippage)
} else {
mid * (1.0 - slippage)
};
Decimal::from_f64_retain(price.round()).unwrap_or_default()
} else {
order.get_limit_price().unwrap_or(trigger_px).round()
};
let trigger_px_rounded = trigger_px.round();
let cloid = {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let nanos = now.as_nanos() as u64;
let hi = nanos.wrapping_mul(0x517cc1b727220a95);
format!("0x{:016x}{:016x}", nanos, hi)
};
let action = json!({
"type": "order",
"orders": [{
"a": asset_index,
"b": order.get_side().is_buy(),
"p": limit_px.normalize().to_string(),
"s": size_rounded.normalize().to_string(),
"r": order.is_reduce_only(),
"t": {
"trigger": {
"isMarket": order.is_market(),
"triggerPx": trigger_px_rounded.normalize().to_string(),
"tpsl": order.get_tpsl().to_string(),
}
},
"c": cloid,
}],
"grouping": "na",
});
let response = self.inner.build_sign_send(&action, None).await?;
Ok(PlacedOrder::from_response(
response,
asset.to_string(),
order.get_side(),
size,
Some(limit_px),
Some(self.inner.clone()),
))
}
pub async fn stop_loss(
&self,
asset: &str,
size: f64,
trigger_price: f64,
) -> Result<PlacedOrder> {
self.trigger_order(
TriggerOrder::stop_loss(asset)
.size(size)
.trigger_price(trigger_price)
.market(),
)
.await
}
pub async fn take_profit(
&self,
asset: &str,
size: f64,
trigger_price: f64,
) -> Result<PlacedOrder> {
self.trigger_order(
TriggerOrder::take_profit(asset)
.size(size)
.trigger_price(trigger_price)
.market(),
)
.await
}
async fn place_order(
&self,
asset: &str,
side: Side,
size: f64,
price: Option<f64>,
tif: TIF,
reduce_only: bool,
is_market: bool,
size_from_notional: bool,
slippage: Option<f64>,
priority_fee: Option<u64>,
) -> Result<PlacedOrder> {
if is_prediction_asset(asset) && priority_fee.is_some() {
return Err(Error::ValidationError(
"priority_fee is not supported for HIP-4 prediction markets. Omit priority_fee when trading market.yes, market.no, or # markets.".to_string(),
));
}
let sz_decimals = if is_prediction_asset(asset) {
0
} else {
self.inner
.metadata
.get_asset(asset)
.map(|a| a.sz_decimals)
.unwrap_or(5)
} as i32;
let size_rounded = if is_prediction_asset(asset) {
size.ceil()
} else {
(size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals)
};
if is_prediction_asset(asset)
&& !size_from_notional
&& (size_rounded - size).abs() > f64::EPSILON
{
return Err(Error::ValidationError(
"HIP-4 prediction market size must be a whole number of contracts".to_string(),
));
}
if is_prediction_asset(asset) {
let px = match price {
Some(px) => px,
None if is_market => self.inner.get_mid_price(asset).await?,
None => 0.0,
};
if px > 0.0 && size_rounded * px < 10.0 {
return Err(Error::ValidationError(
"HIP-4 prediction market orders must have minimum value of 10 USDH. Increase size or price, or call sdk.buy_usdh(...) before trading.".to_string(),
));
}
}
let (action, effective_slippage) = if is_market {
let mut order_spec = json!({
"asset": asset,
"side": if side.is_buy() { "buy" } else { "sell" },
"size": format!("{}", size_rounded),
"tif": "market",
});
if reduce_only {
order_spec["reduceOnly"] = json!(true);
}
let action = json!({
"type": "order",
"orders": [order_spec],
});
(action, slippage)
} else {
let asset_index = self
.inner
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let resolved_price = match price {
Some(px) if is_prediction_asset(asset) => px,
Some(px) => px.round(),
None => 0.0,
};
let tif_wire = match tif {
TIF::Ioc => "Ioc",
TIF::Gtc => "Gtc",
TIF::Alo => "Alo",
TIF::Market => "Ioc",
};
let cloid = {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let nanos = now.as_nanos() as u64;
let hi = nanos.wrapping_mul(0x517cc1b727220a95);
format!("0x{:016x}{:016x}", nanos, hi)
};
let action = json!({
"type": "order",
"orders": [{
"a": asset_index,
"b": side.is_buy(),
"p": format!("{}", resolved_price),
"s": format!("{}", size_rounded),
"r": reduce_only,
"t": {"limit": {"tif": tif_wire}},
"c": cloid,
}],
"grouping": "na",
});
(action, None) };
let response = self
.inner
.build_sign_send_with_priority(&action, effective_slippage, priority_fee)
.await?;
Ok(PlacedOrder::from_response(
response,
asset.to_string(),
side,
Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
price.map(|p| Decimal::from_f64_retain(p).unwrap_or_default()),
Some(self.inner.clone()),
))
}
pub async fn modify(
&self,
oid: u64,
asset: &str,
is_buy: bool,
size: f64,
price: f64,
tif: TIF,
reduce_only: bool,
cloid: Option<&str>,
) -> Result<PlacedOrder> {
let asset_idx = self
.inner
.metadata
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let sz_decimals = self.inner.metadata.get_asset(asset)
.map(|a| a.sz_decimals)
.unwrap_or(8) as i32;
let size_rounded = (size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals);
let order_type = match tif {
TIF::Gtc => json!({"limit": {"tif": "Gtc"}}),
TIF::Ioc | TIF::Market => json!({"limit": {"tif": "Ioc"}}),
TIF::Alo => json!({"limit": {"tif": "Alo"}}),
};
let cloid_val = cloid
.map(|s| s.to_string())
.unwrap_or_else(|| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let nanos = now.as_nanos() as u64;
let hi = nanos.wrapping_mul(0x517cc1b727220a95);
format!("0x{:016x}{:016x}", nanos, hi)
});
let action = json!({
"type": "batchModify",
"modifies": [{
"oid": oid,
"order": {
"a": asset_idx,
"b": is_buy,
"p": format!("{:.8}", price).trim_end_matches('0').trim_end_matches('.'),
"s": format!("{:.8}", size_rounded).trim_end_matches('0').trim_end_matches('.'),
"r": reduce_only,
"t": order_type,
"c": cloid_val,
}
}]
});
let response = self.inner.build_sign_send(&action, None).await?;
Ok(PlacedOrder::from_response(
response,
asset.to_string(),
if is_buy { Side::Buy } else { Side::Sell },
Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
Some(Decimal::from_f64_retain(price).unwrap_or_default()),
Some(self.inner.clone()),
))
}
pub async fn cancel(&self, oid: u64, asset: &str) -> Result<Value> {
self.inner.cancel_by_oid(oid, asset).await
}
pub async fn cancel_all(&self, asset: Option<&str>) -> Result<Value> {
if self.inner.address.is_none() {
return Err(Error::ConfigError("No address configured".to_string()));
}
let open_orders = self.open_orders().await?;
let cancels: Vec<Value> = open_orders
.as_array()
.unwrap_or(&vec![])
.iter()
.filter(|order| {
if let Some(asset) = asset {
order.get("coin").and_then(|c| c.as_str()) == Some(asset)
} else {
true
}
})
.filter_map(|order| {
let oid = order.get("oid").and_then(|o| o.as_u64())?;
let coin = order.get("coin").and_then(|c| c.as_str())?;
let asset_index = self.inner.resolve_asset(coin)?;
Some(json!({"a": asset_index, "o": oid}))
})
.collect();
if cancels.is_empty() {
return Ok(json!({"status": "ok", "message": "No orders to cancel"}));
}
let action = json!({
"type": "cancel",
"cancels": cancels,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn close_position(&self, asset: &str, slippage: Option<f64>) -> Result<PlacedOrder> {
let address = self
.inner
.address
.ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
let action = json!({
"type": "closePosition",
"asset": asset,
"user": format!("{:?}", address),
});
let response = self.inner.build_sign_send(&action, slippage).await?;
Ok(PlacedOrder::from_response(
response,
asset.to_string(),
Side::Sell, Decimal::ZERO, None,
Some(self.inner.clone()),
))
}
pub async fn update_leverage(
&self,
asset: &str,
leverage: i32,
is_cross: bool,
) -> Result<Value> {
let asset_index = self
.inner
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let action = json!({
"type": "updateLeverage",
"asset": asset_index,
"isCross": is_cross,
"leverage": leverage,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn update_isolated_margin(
&self,
asset: &str,
is_buy: bool,
amount_usd: f64,
) -> Result<Value> {
let asset_index = self
.inner
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let action = json!({
"type": "updateIsolatedMargin",
"asset": asset_index,
"isBuy": is_buy,
"ntli": (amount_usd * 1_000_000.0) as i64, });
self.inner.build_sign_send(&action, None).await
}
pub async fn twap_order(
&self,
asset: &str,
size: f64,
is_buy: bool,
duration_minutes: i64,
reduce_only: bool,
randomize: bool,
) -> Result<Value> {
let action = json!({
"type": "twapOrder",
"twap": {
"a": asset,
"b": is_buy,
"s": format!("{}", size),
"r": reduce_only,
"m": duration_minutes,
"t": randomize,
}
});
self.inner.build_sign_send(&action, None).await
}
pub async fn twap_cancel(&self, asset: &str, twap_id: i64) -> Result<Value> {
let action = json!({
"type": "twapCancel",
"a": asset,
"t": twap_id,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn transfer_usd(&self, destination: &str, amount: f64) -> Result<Value> {
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let action = json!({
"type": "usdSend",
"hyperliquidChain": self.inner.chain.to_string(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"destination": destination,
"amount": format!("{}", amount),
"time": time,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn transfer_spot(
&self,
token: &str,
destination: &str,
amount: f64,
) -> Result<Value> {
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let action = json!({
"type": "spotSend",
"hyperliquidChain": self.inner.chain.to_string(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"token": token,
"destination": destination,
"amount": format!("{}", amount),
"time": time,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn withdraw(&self, amount: f64, destination: Option<&str>) -> Result<Value> {
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let dest = destination
.map(|s| s.to_string())
.or_else(|| self.inner.address.map(|a| format!("{:?}", a)))
.ok_or_else(|| Error::ConfigError("No destination address".to_string()))?;
let action = json!({
"type": "withdraw3",
"hyperliquidChain": self.inner.chain.to_string(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"destination": dest,
"amount": format!("{}", amount),
"time": time,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn transfer_spot_to_perp(&self, amount: f64) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let action = json!({
"type": "usdClassTransfer",
"hyperliquidChain": self.inner.chain.to_string(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"amount": format!("{}", amount),
"toPerp": true,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn transfer_perp_to_spot(&self, amount: f64) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let action = json!({
"type": "usdClassTransfer",
"hyperliquidChain": self.inner.chain.to_string(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"amount": format!("{}", amount),
"toPerp": false,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn vault_deposit(&self, vault_address: &str, amount: f64) -> Result<Value> {
let action = json!({
"type": "vaultTransfer",
"vaultAddress": vault_address,
"isDeposit": true,
"usd": amount,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn vault_withdraw(&self, vault_address: &str, amount: f64) -> Result<Value> {
let action = json!({
"type": "vaultTransfer",
"vaultAddress": vault_address,
"isDeposit": false,
"usd": amount,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn buy_usdh(&self, amount_usdc: f64) -> Result<PlacedOrder> {
self.market_buy("@230").await.notional(amount_usdc).await
}
pub async fn sell_usdh(&self, amount_usdh: f64) -> Result<PlacedOrder> {
self.market_sell("@230").await.size(amount_usdh).await
}
pub async fn stake(&self, amount_tokens: f64) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let wei = hype_to_wei(amount_tokens)?;
let action = json!({
"type": "cDeposit",
"wei": wei,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn fund_priority_fees(&self, amount_hype: f64) -> Result<Value> {
self.stake(amount_hype).await
}
pub async fn unstake(&self, amount_tokens: f64) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let wei = hype_to_wei(amount_tokens)?;
let action = json!({
"type": "cWithdraw",
"wei": wei,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn delegate(&self, validator: &str, amount_tokens: f64) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let wei = hype_to_wei(amount_tokens)?;
let action = json!({
"type": "tokenDelegate",
"validator": validator,
"isUndelegate": false,
"wei": wei,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn undelegate(&self, validator: &str, amount_tokens: f64) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let wei = hype_to_wei(amount_tokens)?;
let action = json!({
"type": "tokenDelegate",
"validator": validator,
"isUndelegate": true,
"wei": wei,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn approve_builder_fee(&self, max_fee: Option<&str>) -> Result<Value> {
let fee = max_fee.unwrap_or(&self.max_fee);
let action = json!({
"type": "approveBuilderFee",
"maxFeeRate": fee,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn revoke_builder_fee(&self) -> Result<Value> {
self.approve_builder_fee(Some("0%")).await
}
pub async fn approval_status(&self) -> Result<Value> {
let address = self
.inner
.address
.ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
let url = format!("{}/approval", DEFAULT_WORKER_URL);
let response = self
.inner
.http_client
.post(&url)
.json(&json!({"user": format!("{:?}", address)}))
.send()
.await?;
let text = response.text().await?;
serde_json::from_str(&text).map_err(|e| Error::JsonError(e.to_string()))
}
pub async fn reserve_request_weight(&self, weight: i32) -> Result<Value> {
let action = json!({
"type": "reserveRequestWeight",
"weight": weight,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn noop(&self) -> Result<Value> {
let action = json!({"type": "noop"});
self.inner.build_sign_send(&action, None).await
}
pub async fn preflight(
&self,
asset: &str,
side: Side,
price: f64,
size: f64,
) -> Result<Value> {
let url = format!("{}/preflight", DEFAULT_WORKER_URL);
let body = json!({
"asset": asset,
"side": side.to_string(),
"price": price,
"size": size,
});
let response = self
.inner
.http_client
.post(&url)
.json(&body)
.send()
.await?;
let text = response.text().await?;
serde_json::from_str(&text).map_err(|e| Error::JsonError(e.to_string()))
}
pub async fn approve_agent(
&self,
agent_address: &str,
name: Option<&str>,
) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let action = json!({
"type": "approveAgent",
"hyperliquidChain": self.inner.chain.as_str(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"agentAddress": agent_address,
"agentName": name,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn set_abstraction(&self, mode: &str, user: Option<&str>) -> Result<Value> {
let address = self
.inner
.address
.ok_or_else(|| Error::ConfigError("No address configured".to_string()))?;
let addr_string = format!("{:?}", address);
let user_addr = user.unwrap_or(&addr_string);
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let action = json!({
"type": "userSetAbstraction",
"hyperliquidChain": self.inner.chain.as_str(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"user": user_addr,
"abstraction": mode,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn agent_set_abstraction(&self, mode: &str) -> Result<Value> {
let short_mode = match mode {
"disabled" | "i" => "i",
"unifiedAccount" | "u" => "u",
"portfolioMargin" | "p" => "p",
_ => {
return Err(Error::ValidationError(format!(
"Invalid mode: {}. Use 'disabled', 'unifiedAccount', or 'portfolioMargin'",
mode
)))
}
};
let action = json!({
"type": "agentSetAbstraction",
"abstraction": short_mode,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn send_asset(
&self,
token: &str,
amount: f64,
destination: &str,
source_dex: Option<&str>,
destination_dex: Option<&str>,
from_sub_account: Option<&str>,
) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let action = json!({
"type": "sendAsset",
"hyperliquidChain": self.inner.chain.as_str(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"destination": destination,
"sourceDex": source_dex.unwrap_or(""),
"destinationDex": destination_dex.unwrap_or(""),
"token": token,
"amount": amount.to_string(),
"fromSubAccount": from_sub_account.unwrap_or(""),
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn send_to_evm_with_data(
&self,
token: &str,
amount: f64,
destination: &str,
data: &str,
source_dex: &str,
destination_chain_id: u32,
gas_limit: u64,
) -> Result<Value> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let action = json!({
"type": "sendToEvmWithData",
"hyperliquidChain": self.inner.chain.as_str(),
"signatureChainId": self.inner.chain.signature_chain_id(),
"token": token,
"amount": amount.to_string(),
"sourceDex": source_dex,
"destinationRecipient": destination,
"addressEncoding": "hex",
"destinationChainId": destination_chain_id,
"gasLimit": gas_limit,
"data": data,
"nonce": nonce,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn top_up_isolated_only_margin(
&self,
asset: &str,
leverage: f64,
) -> Result<Value> {
let asset_idx = self
.inner
.metadata
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let action = json!({
"type": "topUpIsolatedOnlyMargin",
"asset": asset_idx,
"leverage": leverage.to_string(),
});
self.inner.build_sign_send(&action, None).await
}
pub async fn validator_l1_stream(&self, risk_free_rate: &str) -> Result<Value> {
let action = json!({
"type": "validatorL1Stream",
"riskFreeRate": risk_free_rate,
});
self.inner.build_sign_send(&action, None).await
}
pub async fn cancel_by_cloid(&self, cloid: &str, asset: &str) -> Result<Value> {
let asset_idx = self
.inner
.metadata
.resolve_asset(asset)
.ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?;
let action = json!({
"type": "cancelByCloid",
"cancels": [{"asset": asset_idx, "cloid": cloid}],
});
self.inner.build_sign_send(&action, None).await
}
pub async fn schedule_cancel(&self, time_ms: Option<u64>) -> Result<Value> {
let mut action = json!({"type": "scheduleCancel"});
if let Some(t) = time_ms {
action["time"] = json!(t);
}
self.inner.build_sign_send(&action, None).await
}
pub async fn get_mid(&self, asset: impl Into<String>) -> Result<f64> {
let asset = asset.into();
self.inner.get_mid_price(&asset).await
}
pub async fn refresh_markets(&self) -> Result<()> {
self.inner.refresh_metadata().await
}
}
pub struct MarketOrderBuilder {
inner: Arc<HyperliquidSDKInner>,
asset: String,
side: Side,
size: Option<f64>,
notional: Option<f64>,
slippage: Option<f64>,
reduce_only: bool,
priority_fee: Option<u64>,
}
impl MarketOrderBuilder {
fn new(inner: Arc<HyperliquidSDKInner>, asset: String, side: Side) -> Self {
Self {
inner,
asset,
side,
size: None,
notional: None,
slippage: None,
reduce_only: false,
priority_fee: None,
}
}
pub fn size(mut self, size: f64) -> Self {
self.size = Some(size);
self
}
pub fn notional(mut self, notional: f64) -> Self {
self.notional = Some(notional);
self
}
pub fn slippage(mut self, slippage: f64) -> Self {
self.slippage = Some(slippage);
self
}
pub fn reduce_only(mut self) -> Self {
self.reduce_only = true;
self
}
pub fn priority_fee(mut self, p: u64) -> Self {
self.priority_fee = Some(p);
self
}
pub async fn execute(self) -> Result<PlacedOrder> {
if is_prediction_asset(&self.asset) && self.priority_fee.is_some() {
return Err(Error::ValidationError(
"priority_fee is not supported for HIP-4 prediction markets. Omit priority_fee when trading market.yes, market.no, or # markets.".to_string(),
));
}
let sz_decimals = if is_prediction_asset(&self.asset) {
0
} else {
self.inner
.metadata
.get_asset(&self.asset)
.map(|a| a.sz_decimals)
.unwrap_or(5)
} as i32;
let size = if let Some(s) = self.size {
s
} else if let Some(notional) = self.notional {
let mid = self.inner.get_mid_price(&self.asset).await?;
notional / mid
} else {
return Err(Error::ValidationError(
"Market order must have size or notional".to_string(),
));
};
let size_rounded = if is_prediction_asset(&self.asset) {
size.ceil()
} else {
(size * 10f64.powi(sz_decimals)).round() / 10f64.powi(sz_decimals)
};
if is_prediction_asset(&self.asset)
&& self.notional.is_none()
&& (size_rounded - size).abs() > f64::EPSILON
{
return Err(Error::ValidationError(
"HIP-4 prediction market size must be a whole number of contracts".to_string(),
));
}
if is_prediction_asset(&self.asset) {
let mid = self.inner.get_mid_price(&self.asset).await.ok();
if mid.is_some_and(|mid| mid > 0.0 && size_rounded * mid < 10.0) {
return Err(Error::ValidationError(
"HIP-4 prediction market orders must have minimum value of 10 USDH. Increase size or price, or call sdk.buy_usdh(...) before trading.".to_string(),
));
}
}
let mut order_spec = json!({
"asset": self.asset,
"side": if self.side.is_buy() { "buy" } else { "sell" },
"size": format!("{}", size_rounded),
"tif": "market",
});
if self.reduce_only {
order_spec["reduceOnly"] = json!(true);
}
let action = json!({
"type": "order",
"orders": [order_spec],
});
let response = self
.inner
.build_sign_send_with_priority(&action, self.slippage, self.priority_fee)
.await?;
Ok(PlacedOrder::from_response(
response,
self.asset,
self.side,
Decimal::from_f64_retain(size_rounded).unwrap_or_default(),
None,
Some(self.inner),
))
}
}
impl std::future::IntoFuture for MarketOrderBuilder {
type Output = Result<PlacedOrder>;
type IntoFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.execute())
}
}