Skip to content

Commit bf9e299

Browse files
Michael YuanMichael Yuan
authored andcommitted
feat: HIP-3 perp support — SILVER, GOLD, stocks on Hyperliquid
- New hip3.rs module: full EIP-712 signing for HIP-3 dex orders - Resolves dex offset (110000 + dex_idx * 10000) from perpDexs API - Msgpack serialization → keccak256 → Agent EIP-712 signing - Bypasses SDK which doesn't support HIP-3 assets - perp quote SILVER/GOLD/TSLA/NVDA etc. — queries HIP-3 cash dex - Shows funding, OI, volume, oracle, premium, max leverage (20x) - perp buy/sell routes HIP-3 assets through hip3::place_order() - Symbol mapping: SILVER→cash:SILVER, GOLD→cash:GOLD, stock tickers→cash:TICKER - Falls back: main perps → HIP-3 dexes → spot commodity pairs - Added rmp-serde dep for msgpack wire format
1 parent 476a964 commit bf9e299

File tree

5 files changed

+532
-52
lines changed

5 files changed

+532
-52
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ urlencoding = "2"
2727
regex = "1"
2828
openssl = { version = "0.10", features = ["vendored"] }
2929
hmac = "0.12"
30+
rmp-serde = "1"
3031
sha2 = "0.10"
3132
uuid = { version = "1", features = ["v4"] }

src/commands/perp.rs

Lines changed: 53 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use anyhow::{bail, Context, Result};
22
use colored::Colorize;
33
use serde_json::json;
44

5-
use crate::commands::quote::commodity_to_spot_token;
6-
use crate::{binance, config, signing};
5+
use crate::commands::quote::resolve_hip3_asset;
6+
use crate::{binance, config, hip3, signing};
77

88
/// Resolve which exchange to use
99
fn resolve_exchange(exchange: &str) -> Result<String> {
@@ -69,9 +69,9 @@ pub async fn buy(
6969
let amount_f: f64 = amount_usdc.parse().context("Invalid amount")?;
7070
let size = amount_f / price_f;
7171

72-
// Check if this is a commodity → route to spot order
73-
if let Some(spot_token) = commodity_to_spot_token(&symbol) {
74-
return commodity_spot_buy(spot_token, &symbol, price_f, size, &cfg, json_output).await;
72+
// Check if this is a HIP-3 asset (commodities, stocks)
73+
if let Some((dex, asset_name)) = resolve_hip3_asset(&symbol) {
74+
return hip3_perp_buy(&dex, &asset_name, &symbol, price_f, size, amount_usdc, &cfg, json_output).await;
7575
}
7676

7777
if !json_output {
@@ -156,9 +156,9 @@ pub async fn sell(
156156
let size: f64 = amount.parse().context("Invalid amount")?;
157157
let price_f: f64 = price.parse().context("Invalid price")?;
158158

159-
// Check if this is a commodity → route to spot order
160-
if let Some(spot_token) = commodity_to_spot_token(&symbol) {
161-
return commodity_spot_sell(spot_token, &symbol, price_f, size, &cfg, json_output).await;
159+
// Check if this is a HIP-3 asset (commodities, stocks)
160+
if let Some((dex, asset_name)) = resolve_hip3_asset(&symbol) {
161+
return hip3_perp_sell(&dex, &asset_name, &symbol, price_f, size, &cfg, json_output).await;
162162
}
163163

164164
if !json_output {
@@ -203,71 +203,74 @@ pub async fn sell(
203203
Ok(())
204204
}
205205

206-
// ── Commodity spot order helpers ─────────────────────────────────────
206+
// ── HIP-3 perp order helpers ─────────────────────────────────────────
207207

208-
/// Route a commodity "perp buy" to a spot buy on HL
209-
async fn commodity_spot_buy(
210-
spot_token: &str,
208+
/// Place a HIP-3 perp buy order (e.g. cash:SILVER)
209+
#[allow(clippy::too_many_arguments)]
210+
async fn hip3_perp_buy(
211+
dex: &str,
212+
asset_name: &str,
211213
original_symbol: &str,
212214
price: f64,
213215
size: f64,
216+
amount_usdc: &str,
214217
cfg: &config::HlConfig,
215218
json_output: bool,
216219
) -> Result<()> {
217220
if !json_output {
218221
println!();
219222
println!(
220-
" 📝 {} trades as {}/USDC spot on Hyperliquid",
221-
original_symbol,
222-
spot_token
223+
" 📝 Placing HIP-3 perp limit BUY ({} on {} dex)",
224+
asset_name.cyan(),
225+
dex
223226
);
224-
println!(" Placing spot limit BUY");
225-
println!(" Token: {}", spot_token.cyan());
227+
println!(" Symbol: {}", original_symbol.cyan());
228+
println!(" Asset: {}", asset_name);
226229
println!(" Size: {:.6}", size);
227230
println!(" Price: ${:.2}", price);
228-
println!(" Total: ${:.2}", price * size);
231+
println!(" Total: ${}", amount_usdc);
229232
println!(
230233
" Network: {}",
231234
if cfg.testnet { "Testnet" } else { "Mainnet" }
232235
);
233236
println!();
234237
}
235238

236-
let result = signing::place_spot_order(spot_token, true, price, size).await?;
239+
let result = hip3::place_order(dex, asset_name, true, price, size).await?;
237240

238241
let response = json!({
239-
"action": "commodity_spot_buy",
242+
"action": "hip3_perp_buy",
240243
"symbol": original_symbol,
241-
"spotToken": spot_token,
244+
"hip3Asset": asset_name,
245+
"dex": dex,
242246
"size": format!("{:.6}", size),
243247
"price": format!("{:.2}", price),
244-
"total_usdc": format!("{:.2}", price * size),
248+
"total_usdc": amount_usdc,
245249
"network": if cfg.testnet { "testnet" } else { "mainnet" },
246-
"note": format!("{} trades as {}/USDC spot pair (no perp available)", original_symbol, spot_token),
247-
"result": format!("{:?}", result),
250+
"result": result,
248251
});
249252

250253
if json_output {
251254
println!("{}", serde_json::to_string_pretty(&response)?);
252255
} else {
253-
match result {
254-
hyperliquid_rust_sdk::ExchangeResponseStatus::Ok(data) => {
255-
println!(" ✅ Spot order placed!");
256-
println!(" Response: {:?}", data);
257-
}
258-
hyperliquid_rust_sdk::ExchangeResponseStatus::Err(e) => {
259-
println!(" ❌ Order failed: {}", e);
256+
if let Some(status) = result.get("status").and_then(|s| s.as_str()) {
257+
if status == "ok" {
258+
println!(" ✅ HIP-3 perp order placed!");
259+
} else {
260+
println!(" ❌ Order failed: {}", status);
260261
}
261262
}
263+
println!(" Response: {}", serde_json::to_string_pretty(&result)?);
262264
println!();
263265
}
264266

265267
Ok(())
266268
}
267269

268-
/// Route a commodity "perp sell" to a spot sell on HL
269-
async fn commodity_spot_sell(
270-
spot_token: &str,
270+
/// Place a HIP-3 perp sell order
271+
async fn hip3_perp_sell(
272+
dex: &str,
273+
asset_name: &str,
271274
original_symbol: &str,
272275
price: f64,
273276
size: f64,
@@ -277,12 +280,12 @@ async fn commodity_spot_sell(
277280
if !json_output {
278281
println!();
279282
println!(
280-
" 📝 {} trades as {}/USDC spot on Hyperliquid",
281-
original_symbol,
282-
spot_token
283+
" 📝 Placing HIP-3 perp limit SELL ({} on {} dex)",
284+
asset_name.cyan(),
285+
dex
283286
);
284-
println!(" Placing spot limit SELL");
285-
println!(" Token: {}", spot_token.cyan());
287+
println!(" Symbol: {}", original_symbol.cyan());
288+
println!(" Asset: {}", asset_name);
286289
println!(" Size: {:.6}", size);
287290
println!(" Price: ${:.2}", price);
288291
println!(
@@ -292,31 +295,30 @@ async fn commodity_spot_sell(
292295
println!();
293296
}
294297

295-
let result = signing::place_spot_order(spot_token, false, price, size).await?;
298+
let result = hip3::place_order(dex, asset_name, false, price, size).await?;
296299

297300
let response = json!({
298-
"action": "commodity_spot_sell",
301+
"action": "hip3_perp_sell",
299302
"symbol": original_symbol,
300-
"spotToken": spot_token,
303+
"hip3Asset": asset_name,
304+
"dex": dex,
301305
"size": format!("{:.6}", size),
302306
"price": format!("{:.2}", price),
303307
"network": if cfg.testnet { "testnet" } else { "mainnet" },
304-
"note": format!("{} trades as {}/USDC spot pair (no perp available)", original_symbol, spot_token),
305-
"result": format!("{:?}", result),
308+
"result": result,
306309
});
307310

308311
if json_output {
309312
println!("{}", serde_json::to_string_pretty(&response)?);
310313
} else {
311-
match result {
312-
hyperliquid_rust_sdk::ExchangeResponseStatus::Ok(data) => {
313-
println!(" ✅ Spot order placed!");
314-
println!(" Response: {:?}", data);
315-
}
316-
hyperliquid_rust_sdk::ExchangeResponseStatus::Err(e) => {
317-
println!(" ❌ Order failed: {}", e);
314+
if let Some(status) = result.get("status").and_then(|s| s.as_str()) {
315+
if status == "ok" {
316+
println!(" ✅ HIP-3 perp order placed!");
317+
} else {
318+
println!(" ❌ Order failed: {}", status);
318319
}
319320
}
321+
println!(" Response: {}", serde_json::to_string_pretty(&result)?);
320322
println!();
321323
}
322324

src/commands/quote.rs

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,12 @@ async fn fetch_hl_perp(client: &reqwest::Client, symbol: &str) -> Result<Value>
357357
}
358358
}
359359

360-
// Not found in perps — check if it's a commodity with a spot pair
360+
// Not found in main perps — check HIP-3 dexes (e.g. cash:SILVER, cash:GOLD)
361+
if let Ok(data) = fetch_hip3_perp(client, symbol).await {
362+
return Ok(data);
363+
}
364+
365+
// Last resort — check if it's a commodity with a spot pair only
361366
let commodity_token = commodity_to_spot_token(symbol);
362367
if let Some(token) = commodity_token {
363368
return fetch_hl_commodity_as_perp(client, symbol, token).await;
@@ -366,6 +371,161 @@ async fn fetch_hl_perp(client: &reqwest::Client, symbol: &str) -> Result<Value>
366371
anyhow::bail!("Symbol {} not found in Hyperliquid perps", symbol)
367372
}
368373

374+
/// Map user-friendly names to HIP-3 dex:symbol pairs
375+
/// Returns (dex_name, asset_name_in_dex) e.g. ("cash", "cash:SILVER")
376+
fn hip3_symbol_map(symbol: &str) -> Vec<(&'static str, String)> {
377+
let sym = symbol.to_uppercase();
378+
379+
// If already prefixed with dex name, use directly
380+
if sym.contains(':') {
381+
let parts: Vec<&str> = sym.splitn(2, ':').collect();
382+
if parts.len() == 2 {
383+
return vec![(
384+
match parts[0].to_lowercase().as_str() {
385+
"cash" => "cash",
386+
"xyz" => "xyz",
387+
"flx" => "flx",
388+
"km" => "km",
389+
_ => return vec![],
390+
},
391+
sym.to_lowercase(),
392+
)];
393+
}
394+
}
395+
396+
// Map common aliases to the most liquid HIP-3 dex
397+
// cash (dreamcash) is the most liquid for commodities and stocks
398+
let mapped = match sym.as_str() {
399+
// Commodities
400+
"SILVER" | "XAG" => Some("SILVER"),
401+
"GOLD" | "XAU" => Some("GOLD"),
402+
// US indices
403+
"USA500" | "SP500" | "SPX" => Some("USA500"),
404+
// Stocks available on cash dex
405+
"TSLA" | "NVDA" | "GOOGL" | "AMZN" | "MSFT" | "META" | "INTC" | "HOOD" => {
406+
Some(sym.as_str())
407+
}
408+
_ => None,
409+
};
410+
411+
if let Some(asset) = mapped {
412+
// Try cash dex first (most liquid), then others
413+
vec![
414+
("cash", format!("cash:{}", asset)),
415+
("xyz", format!("xyz:{}", asset)),
416+
("km", format!("km:{}", asset)),
417+
]
418+
} else {
419+
vec![]
420+
}
421+
}
422+
423+
/// Fetch perp data from HIP-3 dexes (e.g. cash:SILVER, cash:GOLD)
424+
async fn fetch_hip3_perp(client: &reqwest::Client, symbol: &str) -> Result<Value> {
425+
let candidates = hip3_symbol_map(symbol);
426+
if candidates.is_empty() {
427+
anyhow::bail!("No HIP-3 mapping for {}", symbol);
428+
}
429+
430+
let url = config::info_url();
431+
432+
for (dex, asset_name) in &candidates {
433+
let meta_resp: Value = client
434+
.post(&url)
435+
.json(&json!({"type": "metaAndAssetCtxs", "dex": dex}))
436+
.send()
437+
.await?
438+
.json()
439+
.await
440+
.context("Failed to parse HIP-3 metaAndAssetCtxs")?;
441+
442+
if let (Some(universe), Some(ctxs)) = (
443+
meta_resp
444+
.get(0)
445+
.and_then(|m| m.get("universe"))
446+
.and_then(|u| u.as_array()),
447+
meta_resp.get(1).and_then(|c| c.as_array()),
448+
) {
449+
for (i, asset) in universe.iter().enumerate() {
450+
let name = asset.get("name").and_then(|n| n.as_str()).unwrap_or("");
451+
if name.eq_ignore_ascii_case(asset_name) {
452+
if let Some(ctx) = ctxs.get(i) {
453+
let funding = ctx
454+
.get("funding")
455+
.and_then(|f| f.as_str())
456+
.unwrap_or("")
457+
.to_string();
458+
let open_interest = ctx
459+
.get("openInterest")
460+
.and_then(|f| f.as_str())
461+
.unwrap_or("")
462+
.to_string();
463+
let volume = ctx
464+
.get("dayNtlVlm")
465+
.and_then(|f| f.as_str())
466+
.unwrap_or("")
467+
.to_string();
468+
let mark_px = ctx
469+
.get("markPx")
470+
.and_then(|f| f.as_str())
471+
.unwrap_or("")
472+
.to_string();
473+
let oracle_px = ctx
474+
.get("oraclePx")
475+
.and_then(|f| f.as_str())
476+
.unwrap_or("")
477+
.to_string();
478+
let prev_day_px = ctx
479+
.get("prevDayPx")
480+
.and_then(|f| f.as_str())
481+
.unwrap_or("")
482+
.to_string();
483+
let premium = ctx
484+
.get("premium")
485+
.and_then(|f| f.as_str())
486+
.unwrap_or("")
487+
.to_string();
488+
let max_leverage = asset
489+
.get("maxLeverage")
490+
.and_then(|l| l.as_u64())
491+
.unwrap_or(0);
492+
let change_24h = calc_change(&mark_px, &prev_day_px);
493+
494+
return Ok(json!({
495+
"symbol": symbol.to_uppercase(),
496+
"hip3Asset": name,
497+
"dex": dex,
498+
"markPx": mark_px,
499+
"oraclePx": oracle_px,
500+
"change24h": change_24h,
501+
"funding": funding,
502+
"premium": premium,
503+
"openInterest": open_interest,
504+
"volume24h": volume,
505+
"prevDayPx": prev_day_px,
506+
"maxLeverage": max_leverage,
507+
"source": format!("Hyperliquid HIP-3 ({})", dex),
508+
}));
509+
}
510+
}
511+
}
512+
}
513+
}
514+
515+
anyhow::bail!("Symbol {} not found in HIP-3 dexes", symbol)
516+
}
517+
518+
/// Resolve a symbol to its HIP-3 dex asset name (for trading)
519+
/// Returns (dex_name, full_asset_name) e.g. ("cash", "cash:SILVER")
520+
pub fn resolve_hip3_asset(symbol: &str) -> Option<(String, String)> {
521+
let candidates = hip3_symbol_map(symbol);
522+
// Return the first candidate (most liquid dex)
523+
candidates
524+
.into_iter()
525+
.next()
526+
.map(|(dex, asset)| (dex.to_string(), asset))
527+
}
528+
369529
/// Map commodity aliases to HL spot token names
370530
pub fn commodity_to_spot_token(symbol: &str) -> Option<&'static str> {
371531
match symbol.to_uppercase().as_str() {

0 commit comments

Comments
 (0)