diff --git a/skills/hyperliquid-plugin/.claude-plugin/plugin.json b/skills/hyperliquid-plugin/.claude-plugin/plugin.json index 53204c377..aeb0bf809 100644 --- a/skills/hyperliquid-plugin/.claude-plugin/plugin.json +++ b/skills/hyperliquid-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hyperliquid-plugin", "description": "Hyperliquid on-chain perpetuals DEX — check positions, get market prices, place and cancel perpetual orders on Hyperliquid L1 (chain_id 999).", - "version": "0.4.4", + "version": "0.4.5", "author": { "name": "GeoGu360", "github": "GeoGu360" diff --git a/skills/hyperliquid-plugin/Cargo.lock b/skills/hyperliquid-plugin/Cargo.lock index 30e5b15aa..657c562d6 100644 --- a/skills/hyperliquid-plugin/Cargo.lock +++ b/skills/hyperliquid-plugin/Cargo.lock @@ -591,7 +591,7 @@ dependencies = [ [[package]] name = "hyperliquid-plugin" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "clap", diff --git a/skills/hyperliquid-plugin/Cargo.toml b/skills/hyperliquid-plugin/Cargo.toml index 8d3eec6a7..c7636408a 100644 --- a/skills/hyperliquid-plugin/Cargo.toml +++ b/skills/hyperliquid-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyperliquid-plugin" -version = "0.4.4" +version = "0.4.5" edition = "2021" [[bin]] diff --git a/skills/hyperliquid-plugin/SKILL.md b/skills/hyperliquid-plugin/SKILL.md index 3f8226a4c..59275a6e9 100644 --- a/skills/hyperliquid-plugin/SKILL.md +++ b/skills/hyperliquid-plugin/SKILL.md @@ -1,7 +1,7 @@ --- name: hyperliquid-plugin description: Hyperliquid DEX — trade perps & spot, deposit from Arbitrum, withdraw to Arbitrum, transfer between perp and spot accounts, manage gas on HyperEVM. -version: "0.4.4" +version: "0.4.5" author: GeoGu360 tags: - perps @@ -26,7 +26,7 @@ tags: # Check for skill updates (1-hour cache) UPDATE_CACHE="$HOME/.plugin-store/update-cache/hyperliquid-plugin" CACHE_MAX=3600 -LOCAL_VER="0.4.4" +LOCAL_VER="0.4.5" DO_CHECK=true if [ -f "$UPDATE_CACHE" ]; then @@ -37,7 +37,7 @@ if [ -f "$UPDATE_CACHE" ]; then fi if [ "$DO_CHECK" = true ]; then - REMOTE_VER=$(curl -sf --max-time 3 "https://raw.githubusercontent.com/mig-pre/plugin-store/main/skills/hyperliquid-plugin/plugin.yaml" | grep '^version' | head -1 | tr -d '"' | awk '{print $2}') + REMOTE_VER=$(curl -sf --max-time 3 "https://raw.githubusercontent.com/okx/plugin-store/main/skills/hyperliquid-plugin/plugin.yaml" | grep '^version' | head -1 | tr -d '"' | awk '{print $2}') if [ -n "$REMOTE_VER" ]; then mkdir -p "$HOME/.plugin-store/update-cache" echo "$REMOTE_VER" > "$UPDATE_CACHE" @@ -47,7 +47,7 @@ fi REMOTE_VER=$(cat "$UPDATE_CACHE" 2>/dev/null || echo "$LOCAL_VER") if [ "$REMOTE_VER" != "$LOCAL_VER" ]; then echo "Update available: hyperliquid-plugin v$LOCAL_VER -> v$REMOTE_VER. Updating..." - npx skills add mig-pre/plugin-store --skill hyperliquid-plugin --yes --global 2>/dev/null || true + npx skills add okx/plugin-store --skill hyperliquid-plugin --yes --global 2>/dev/null || true echo "Updated hyperliquid-plugin to v$REMOTE_VER. Please re-read this SKILL.md." fi ``` @@ -98,7 +98,7 @@ fi npx skills add okx/onchainos-skills --yes --global # 3. Install plugin-store skills (enables plugin discovery and management) -npx skills add mig-pre/plugin-store --skill plugin-store --yes --global +npx skills add okx/plugin-store --skill plugin-store --yes --global ``` ### Install hyperliquid-plugin binary + launcher (auto-injected) @@ -109,11 +109,11 @@ LAUNCHER="$HOME/.plugin-store/launcher.sh" CHECKER="$HOME/.plugin-store/update-checker.py" if [ ! -f "$LAUNCHER" ]; then mkdir -p "$HOME/.plugin-store" - curl -fsSL "https://raw.githubusercontent.com/mig-pre/plugin-store/main/scripts/launcher.sh" -o "$LAUNCHER" 2>/dev/null || true + curl -fsSL "https://raw.githubusercontent.com/okx/plugin-store/main/scripts/launcher.sh" -o "$LAUNCHER" 2>/dev/null || true chmod +x "$LAUNCHER" fi if [ ! -f "$CHECKER" ]; then - curl -fsSL "https://raw.githubusercontent.com/mig-pre/plugin-store/main/scripts/update-checker.py" -o "$CHECKER" 2>/dev/null || true + curl -fsSL "https://raw.githubusercontent.com/okx/plugin-store/main/scripts/update-checker.py" -o "$CHECKER" 2>/dev/null || true fi # Clean up old installation @@ -138,12 +138,12 @@ mkdir -p ~/.local/bin # Download binary + checksums to a sandbox, verify SHA256 before installing. BIN_TMP=$(mktemp -d) -RELEASE_BASE="https://github.com/mig-pre/plugin-store/releases/download/plugins/hyperliquid-plugin@0.4.4" +RELEASE_BASE="https://github.com/okx/plugin-store/releases/download/plugins/hyperliquid-plugin@0.4.5" curl -fsSL "${RELEASE_BASE}/hyperliquid-plugin-${TARGET}${EXT}" -o "$BIN_TMP/hyperliquid-plugin${EXT}" || { echo "ERROR: failed to download hyperliquid-plugin-${TARGET}${EXT}" >&2 rm -rf "$BIN_TMP"; exit 1; } curl -fsSL "${RELEASE_BASE}/checksums.txt" -o "$BIN_TMP/checksums.txt" || { - echo "ERROR: failed to download checksums.txt for hyperliquid-plugin@0.4.4" >&2 + echo "ERROR: failed to download checksums.txt for hyperliquid-plugin@0.4.5" >&2 rm -rf "$BIN_TMP"; exit 1; } EXPECTED=$(awk -v b="hyperliquid-plugin-${TARGET}${EXT}" '$2 == b {print $1; exit}' "$BIN_TMP/checksums.txt") @@ -167,7 +167,7 @@ ln -sf "$LAUNCHER" ~/.local/bin/hyperliquid-plugin # Register version mkdir -p "$HOME/.plugin-store/managed" -echo "0.4.4" > "$HOME/.plugin-store/managed/hyperliquid-plugin" +echo "0.4.5" > "$HOME/.plugin-store/managed/hyperliquid-plugin" ``` --- @@ -1654,6 +1654,21 @@ Found and patched during integration: ## Changelog +### v0.4.5 (2026-05-10) + +Seven UX / safety / correctness fixes surfaced during a HIP-3 NVDA-perp end-to-end reproduction. Each fix is independent; combined diff is 12 files / +317 / -82. + +All write commands continue to require explicit `--confirm`; pre-flight checks added below run before any signing or submission, so risky inputs are caught before user authorization, never after. + +- **fix**: `markets --coin --type tradfi|hip3` was silently routed to spot lookup, ignoring `--type` entirely (`lookup_single` ignored mode). Now searches every builder DEX in parallel and returns the first match; bare-symbol queries against tradfi correctly resolve `NVDA → xyz:NVDA`. (Bug #1) +- **fix**: `order` auto-bump for $10 minimum notional only bumped one tick (`if`, not `while`); for sz_decimals=3 markets like NVDA at $217.5 a `0.010 → 0.011` bump still left $2.39 < $10 and the order would link-revert post-sign. Replaced with `ceil(10 / mid * sz_factor) / sz_factor` — single-shot guaranteed convergence. (Bug #2) +- **fix**: `order` insufficient-perp-balance tip pointed users at `deposit --amount ` even on HIP-3 builder DEX coins; (a) deposit funds the **default DEX** clearinghouse, not the builder dex, so the user's next order would fail again with the same error; (b) the suggested amount could be < $5, the HL bridge minimum (smaller deposits are silently dropped). Now emits `error_code: BUILDER_DEX_UNFUNDED` with two actionable paths: `abstraction --set unified` (one-time, all DEXs share margin) OR explicit dex-transfer chain. Default-DEX path now caps deposit suggestions at $5. (Bug #3) +- **fix**: `deposit` amounts < $5 used to print only an `eprintln!` warning then proceed to sign and broadcast; HL bridge silently drops these (funds lost on Arbitrum side). Now hard-rejected with `error_code: DEPOSIT_BELOW_MIN` — the rejection runs before user `--confirm` is honored, so no signing occurs. (Bug #3 sister bug) +- **fix**: `spot-order --coin` and `spot-prices --token` accepted only those names respectively, despite both referring to the same concept; users typing the "wrong" flag got `unexpected argument`. Added bidirectional clap aliases — both names work in both commands. (Bug #4) +- **fix**: `spot-order` only validated $10 minimum notional when both `--price` and `--size` were supplied (limit orders); market orders below $10 were signed and submitted, then rejected on-chain with `Order must have minimum value of 10 USDC`. Added mid-based notional pre-flight + auto-bump for market, hard-reject (`ORDER_BELOW_MIN_NOTIONAL`) for limit. Both run before sign/submit. (Bug #5) +- **fix**: `round_px` used `sig_figs = sz_decimals.max(1)` per Python SDK comment, but HL spec is **5 sig figs AND ≤ (MAX_DECIMALS - sz_decimals) decimal places**. For sz_decimals=3 markets (NVDA at $217.495) that yielded 3 sig figs → integer round → "217", losing 2pp risk-management precision in TP/SL bracket prices. Fixed to use 5 sig figs with the decimal-place cap; NVDA inputs of `212.06` / `229.46` are now preserved verbatim. (Bug #6) +- **fix**: `orders --coin xyz:NVDA` correctly extracted the dex prefix (per `--help` doc), but the post-fetch filter compared `coin.to_uppercase() != filter` — uppercasing the dex prefix too while the filter kept the prefix lowercase, so reduce-only TP/SL trigger orders on builder DEX coins were never returned. Switched to case-insensitive comparison. (Bug #7) + ### v0.4.3 (2026-05-05) - **fix**: HIP-4 outcome buy/sell missing OKX attribution reporting — `outcome-buy` and `outcome-sell` now invoke `report-plugin-info` after every successful order (filled OR resting) with the same payload shape as perp `order` (`market_id` = trade-context `#N` coin, `symbol` = `USDH`, `asset_id` = the 100M+ outcome asset id, `side` = BUY/SELL). `--strategy-id` flag added (optional attribution tag, empty string when omitted; consistent with v0.4.1 perp behavior). Fixes attribution gap discovered during v0.4.2 post-merge audit — outcome trades placed via v0.4.2 binary are unattributed at the backend. diff --git a/skills/hyperliquid-plugin/plugin.yaml b/skills/hyperliquid-plugin/plugin.yaml index 74ce950a9..e64f758de 100644 --- a/skills/hyperliquid-plugin/plugin.yaml +++ b/skills/hyperliquid-plugin/plugin.yaml @@ -1,6 +1,6 @@ schema_version: 1 name: hyperliquid-plugin -version: "0.4.4" +version: "0.4.5" description: "Trade perpetuals on Hyperliquid - check positions, get prices, place market/limit orders with TP/SL brackets, close positions, deposit USDC" author: name: GeoGu360 diff --git a/skills/hyperliquid-plugin/src/commands/deposit.rs b/skills/hyperliquid-plugin/src/commands/deposit.rs index 088c43d33..299fd1910 100644 --- a/skills/hyperliquid-plugin/src/commands/deposit.rs +++ b/skills/hyperliquid-plugin/src/commands/deposit.rs @@ -79,8 +79,19 @@ pub async fn run(args: DepositArgs) -> anyhow::Result<()> { println!("{}", super::error_response("Amount must be greater than 0", "INVALID_ARGUMENT", "Provide a positive USDC amount with --amount.")); return Ok(()); } + // HL bridge has a hard minimum of $5 USDC. Smaller deposits are silently + // dropped (funds lost on the source chain). Reject up-front so we never + // sign-and-broadcast a transaction that loses user money. if args.amount < 5.0 { - eprintln!("WARNING: Minimum recommended deposit is $5 USDC. Amounts below $5 may not arrive."); + println!( + "{}", + super::error_response( + &format!("Deposit amount ${:.4} is below HL bridge minimum of $5 USDC", args.amount), + "DEPOSIT_BELOW_MIN", + "Hyperliquid's L1 bridge requires at least $5 USDC; smaller deposits are dropped (funds lost). Increase --amount to 5 or more.", + ) + ); + return Ok(()); } // USDC has 6 decimals diff --git a/skills/hyperliquid-plugin/src/commands/markets.rs b/skills/hyperliquid-plugin/src/commands/markets.rs index 28e1f32fe..2cf6d1dac 100644 --- a/skills/hyperliquid-plugin/src/commands/markets.rs +++ b/skills/hyperliquid-plugin/src/commands/markets.rs @@ -99,7 +99,7 @@ pub async fn run(args: MarketsArgs) -> anyhow::Result<()> { // Single-coin lookup short-circuits filters if let Some(coin) = args.coin.as_deref() { - return lookup_single(info, coin).await; + return lookup_single(info, coin, &mode).await; } match mode { @@ -142,26 +142,122 @@ fn resolve_mode(ty: Option<&str>, dex: Option<&str>) -> Result { // ---------- Single-coin lookup ---------- -async fn lookup_single(info: &str, coin: &str) -> anyhow::Result<()> { - // Spot first if no colon and not in any perp universe +async fn lookup_single(info: &str, coin: &str, mode: &Mode) -> anyhow::Result<()> { + // Builder DEX market with explicit prefix ("xyz:CL") always wins over --type: + // user gave a fully-qualified coin, route directly. if coin.contains(':') { - // Builder DEX market: "xyz:CL" let (dex, _) = crate::api::parse_coin(coin); let dex = dex.unwrap_or_default(); return lookup_perp(info, coin, Some(&dex)).await; } - // Try default perp first - let default_meta = get_meta_and_asset_ctxs_for_dex(info, None).await; - if let Ok(meta) = default_meta { - if let Some(entry) = find_perp_market(&meta, coin, None) { - print_perp_single("default", entry); + // No prefix → search where --type / --dex tells us to look. Previous behavior + // (always default-perp then spot) silently dropped builder-DEX matches when + // user passed `--type tradfi --coin NVDA`. + match mode { + Mode::Spot => lookup_spot(info, coin).await, + Mode::PerpDefault => { + // Default DEX → fallback to spot for backward compat + let default_meta = get_meta_and_asset_ctxs_for_dex(info, None).await; + if let Ok(meta) = default_meta { + if let Some(entry) = find_perp_market(&meta, coin, None) { + print_perp_single("default", entry); + return Ok(()); + } + } + lookup_spot(info, coin).await + } + Mode::PerpDex(dex_name) => lookup_perp(info, coin, Some(dex_name)).await, + Mode::PerpBuilders { dedupe_crypto } => { + lookup_across_builders(info, coin, *dedupe_crypto).await + } + Mode::Outcome => { + // Outcome markets are looked up by their full outcome id, not by + // bare token names. Fall back to existing single-asset error path. + println!("{}", super::error_response( + &format!("Outcome lookup by --coin '{}' not supported; use `outcome-list` to discover markets", coin), + "MARKET_NOT_FOUND", + "Use `hyperliquid-plugin outcome-list` to enumerate outcomes, then trade with `outcome-buy --outcome `.", + )); + Ok(()) + } + } +} + +/// Search across all HIP-3 builder DEXs in parallel for the first matching coin. +/// `dedupe_crypto = true` (--type tradfi) skips a builder match if the bare +/// symbol also exists on default DEX (e.g. xyz:BTC dedup'd because BTC is on +/// default). When false (--type hip3) every builder match counts. +async fn lookup_across_builders( + info: &str, + coin: &str, + dedupe_crypto: bool, +) -> anyhow::Result<()> { + let registry = match fetch_perp_dexs(info).await { + Ok(r) => r, + Err(e) => return print_api_err(&e), + }; + + // crypto symbol set on default DEX, used only when dedupe_crypto is on. + let crypto_set: HashSet = if dedupe_crypto { + get_meta_and_asset_ctxs_for_dex(info, None) + .await + .ok() + .as_ref() + .and_then(|m| m.as_array()) + .and_then(|a| a.first()) + .and_then(|m| m["universe"].as_array()) + .map(|u| { + u.iter() + .filter_map(|x| x["name"].as_str()) + .map(|s| s.to_uppercase()) + .collect() + }) + .unwrap_or_default() + } else { + HashSet::new() + }; + + if dedupe_crypto && crypto_set.contains(&coin.to_uppercase()) { + // user said --type tradfi --coin BTC: BTC is a default-DEX crypto, not RWA + println!("{}", super::error_response( + &format!("'{}' is a crypto perp on default DEX, not a tradfi/RWA market. Use --type crypto (or no --type) to look up default-DEX coins.", coin), + "MARKET_NOT_FOUND", + "Run without --type for default DEX, or specify --coin : to force a builder DEX lookup.", + )); + return Ok(()); + } + + // Parallel fetch every builder DEX's meta, take first match. + let futs: Vec<_> = registry + .iter() + .map(|d: &BuilderDex| { + let name = d.name.clone(); + async move { + let meta = get_meta_and_asset_ctxs_for_dex(info, Some(&name)) + .await + .ok(); + (name, meta) + } + }) + .collect(); + let results = futures::future::join_all(futs).await; + + for (dex_name, meta_opt) in results { + let Some(meta) = meta_opt else { continue }; + if let Some(entry) = find_perp_market(&meta, coin, Some(dex_name.clone())) { + print_perp_single(&dex_name, entry); return Ok(()); } } - // Fall back to spot - lookup_spot(info, coin).await + let preset = if dedupe_crypto { "tradfi" } else { "hip3" }; + println!("{}", super::error_response( + &format!("Symbol '{}' not found on any builder DEX (--type {})", coin, preset), + "MARKET_NOT_FOUND", + "Run `hyperliquid-plugin markets --type tradfi` (no --coin) to list all RWA/equity markets, or use `--coin :` if you know the DEX prefix.", + )); + Ok(()) } async fn lookup_perp(info: &str, coin: &str, dex_opt: Option<&str>) -> anyhow::Result<()> { diff --git a/skills/hyperliquid-plugin/src/commands/order.rs b/skills/hyperliquid-plugin/src/commands/order.rs index 88cc2a9b1..dfcb2fe63 100644 --- a/skills/hyperliquid-plugin/src/commands/order.rs +++ b/skills/hyperliquid-plugin/src/commands/order.rs @@ -191,21 +191,30 @@ pub async fn run(args: OrderArgs) -> anyhow::Result<()> { let mid_f = current_price.parse::().unwrap_or(0.0); // ─── Size: round to szDecimals, then auto-bump if notional < $10 ───────── + // + // HL enforces a $10 minimum notional on every perp/spot order; broadcasts + // below this revert with `Order must have minimum value of 10 USDC`. + // Previous logic bumped the size by exactly one tick which often still + // landed below $10 (e.g. NVDA @217.5 sz_decimals=3: 0.010→0.011 = $2.39). + // Compute the smallest size such that size*mid >= $10 directly. let sz_factor = 10_f64.powi(sz_decimals as i32); let mut size_rounded = (size_f * sz_factor).round() / sz_factor; if mid_f > 0.0 { let n = size_rounded * mid_f; if n > 0.0 && n < 10.0 { - let bumped = size_rounded + 1.0 / sz_factor; + // ceil(10 / mid * sz_factor) / sz_factor → smallest grid-aligned size with notional >= $10. + // Add small epsilon to mid for floating-point safety so we don't land + // exactly on $9.999999 due to rounding. + let min_size = (10.0 / mid_f * sz_factor).ceil() / sz_factor; eprintln!( "[auto-adjust] size {} → {} to meet $10 minimum notional (${:.2} → ${:.2})", fmt_size(size_rounded, sz_decimals), - fmt_size(bumped, sz_decimals), + fmt_size(min_size, sz_decimals), n, - bumped * mid_f, + min_size * mid_f, ); - size_rounded = bumped; + size_rounded = min_size; } } let size_str = fmt_size(size_rounded, sz_decimals); @@ -295,25 +304,75 @@ pub async fn run(args: OrderArgs) -> anyhow::Result<()> { }) }); - // Gate: STOP if perp balance is clearly insufficient + // Gate: STOP if perp balance is clearly insufficient. + // + // Tip text differs sharply between default DEX and HIP-3 builder DEX. On a + // builder DEX, `b.perp` is the BUILDER DEX's clearinghouse balance (each is + // isolated by HIP-3 design); naively suggesting `deposit` would route funds + // into default DEX where they cannot back the order. Builder DEX users need + // either dex-transfer or `abstraction --set unified`. + // + // Also: HL bridge minimum is $5; deposit smaller amounts loses funds. + // Cap any `deposit` suggestion to `>= $5`. + const HL_BRIDGE_MIN_USD: f64 = 5.0; if let Some(ref b) = balances_opt { if b.perp < required_margin { let shortfall = required_margin - b.perp; - let tip = if b.spot >= shortfall { - format!( - "Spot has enough USDC. Run: hyperliquid transfer --amount {:.2} --from spot", - shortfall + let (error_code, tip) = if let Some(ref dex) = dex_opt { + let dex = dex.as_str(); + let head = format!( + "{} DEX has its own isolated clearinghouse (HIP-3) — funds on default DEX or other DEXs do NOT back orders here. Two options:", + dex + ); + let opt_a = "(A) Enable cross-DEX margin abstraction (one-time, then all DEXs share margin pool — this is HL Web UI's default behavior): `hyperliquid-plugin abstraction --set unified --confirm`".to_string(); + let opt_b = if b.spot >= shortfall { + format!( + "(B) Move ${:.2} from spot via default DEX: `hyperliquid-plugin transfer --amount {:.2} --direction spot-to-perp --confirm` then `hyperliquid-plugin dex-transfer --to-dex {} --amount {:.2} --confirm`", + shortfall, shortfall, dex, shortfall + ) + } else if b.arb >= shortfall.max(HL_BRIDGE_MIN_USD) { + let bridge_amt = shortfall.max(HL_BRIDGE_MIN_USD); + format!( + "(B) Bridge ${:.2} from Arbitrum (HL bridge minimum is $5): `hyperliquid-plugin deposit --amount {:.2} --confirm` then `hyperliquid-plugin dex-transfer --to-dex {} --amount {:.2} --confirm`", + bridge_amt, bridge_amt, dex, shortfall + ) + } else { + format!( + "(B) Liquid USDC across all wallets is ${:.2} — top up Arbitrum to ≥$5 first, then deposit + dex-transfer to {}. Spot USDH ${:.2} can be sold via `spot-order --coin USDH --side sell --type market --size ` (mind $10 min notional).", + b.spot + b.arb, + dex, + 0.0 // we don't have spot USDH in Balances struct; placeholder + ) + }; + ( + "BUILDER_DEX_UNFUNDED", + format!("{}\n{}\n{}", head, opt_a, opt_b), + ) + } else if b.spot >= shortfall { + ( + "PERP_INSUFFICIENT_BALANCE", + format!( + "Spot has enough USDC. Run: `hyperliquid-plugin transfer --amount {:.2} --direction spot-to-perp --confirm`", + shortfall + ), ) - } else if b.arb >= shortfall { - format!( - "Arbitrum has enough USDC. Run: hyperliquid deposit --amount {:.2}", - shortfall + } else if b.arb >= shortfall.max(HL_BRIDGE_MIN_USD) { + let bridge_amt = shortfall.max(HL_BRIDGE_MIN_USD); + ( + "PERP_INSUFFICIENT_BALANCE", + format!( + "Arbitrum has enough USDC. Run: `hyperliquid-plugin deposit --amount {:.2} --confirm` (HL bridge minimum is $5; smaller deposits lose funds).", + bridge_amt + ), ) } else { - format!( - "Total across all accounts: ${:.2}. Add ${:.2} more USDC (e.g. via `hyperliquid deposit`).", - b.perp + b.spot + b.arb, - shortfall + ( + "PERP_INSUFFICIENT_BALANCE", + format!( + "Total liquid USDC: ${:.2}. Need ${:.2} more. HL bridge minimum is $5 — smaller deposits lose funds.", + b.perp + b.spot + b.arb, + shortfall + ), ) }; println!( @@ -321,6 +380,7 @@ pub async fn run(args: OrderArgs) -> anyhow::Result<()> { serde_json::to_string_pretty(&serde_json::json!({ "ok": false, "error": "Insufficient perp balance", + "error_code": error_code, "notional_usd": format!("${:.2}", notional), "estimated_leverage": format!("{}x", effective_leverage as u32), "required_margin_est": format!("${:.4}", required_margin), diff --git a/skills/hyperliquid-plugin/src/commands/orders.rs b/skills/hyperliquid-plugin/src/commands/orders.rs index 457582407..f0cefe296 100644 --- a/skills/hyperliquid-plugin/src/commands/orders.rs +++ b/skills/hyperliquid-plugin/src/commands/orders.rs @@ -68,7 +68,11 @@ pub async fn run(args: OrdersArgs) -> anyhow::Result<()> { for o in all_orders { let coin = o["coin"].as_str().unwrap_or("?"); if let Some(ref filter) = coin_filter { - if coin.to_uppercase() != *filter { + // HL returns builder-DEX coins as "xyz:NVDA" (dex prefix lowercase, + // symbol uppercase). Filter was built the same way upstream, but + // earlier code did `coin.to_uppercase() != filter` which uppercases + // the dex prefix too → never matches. Use case-insensitive compare. + if !coin.eq_ignore_ascii_case(filter) { continue; } } diff --git a/skills/hyperliquid-plugin/src/commands/spot_order.rs b/skills/hyperliquid-plugin/src/commands/spot_order.rs index e2332f257..7f042a923 100644 --- a/skills/hyperliquid-plugin/src/commands/spot_order.rs +++ b/skills/hyperliquid-plugin/src/commands/spot_order.rs @@ -10,7 +10,9 @@ use crate::signing::{ #[derive(Args)] pub struct SpotOrderArgs { /// Base token to trade (e.g. PURR, HYPE) - #[arg(long)] + /// Spot base token (e.g. USDH, HYPE, PURR). Accepts `--token` as an alias + /// for parity with `spot-prices` and `spot-balances` parameter naming. + #[arg(long, alias = "token")] pub coin: String, /// Side: buy or sell @@ -79,25 +81,6 @@ pub async fn run(args: SpotOrderArgs) -> anyhow::Result<()> { return Ok(()); } - // Pre-flight: check notional value against HL's 10 USDC spot minimum - if let (Some(price_str), Ok(size_f)) = (args.price.as_deref(), args.size.parse::()) { - if let Ok(price_f) = price_str.parse::() { - let notional = size_f * price_f; - if notional < 10.0 { - println!("{}", super::error_response( - &format!( - "Order value {:.4} USDC is below Hyperliquid's 10 USDC minimum for spot orders. \ - Increase --size or --price (current: {} × {} = {:.4} USDC).", - notional, args.size, price_str, notional - ), - "INVALID_ARGUMENT", - "Increase --size or --price so the order value is at least 10 USDC." - )); - return Ok(()); - } - } - } - // Look up spot asset — returns (order_asset_idx, raw_market_idx, sz_decimals) let (asset_idx, market_idx, sz_decimals) = match get_spot_asset_meta(info, &coin).await { Ok(v) => v, @@ -122,13 +105,57 @@ pub async fn run(args: SpotOrderArgs) -> anyhow::Result<()> { .unwrap_or("unknown"); let mid_f = current_price.parse::().unwrap_or(0.0); + + // ─── Notional pre-flight + auto-bump for market orders ─────────────────── + // + // HL enforces a $10 minimum spot notional. Earlier code only checked when + // user passed --price (limit orders), so market orders below $10 silently + // went on-chain and were rejected (`Order must have minimum value of 10 + // USDC. asset=10230`). For market orders we know mid_f → bump size to the + // smallest grid-aligned size satisfying mid * size >= $10. For limit + // orders the user-provided price is authoritative; reject so they can adjust. + let mut size_str_owned: String = args.size.clone(); + if let Ok(size_f) = args.size.parse::() { + let effective_px = match (args.r#type.as_str(), args.price.as_deref()) { + ("limit", Some(p)) => p.parse::().unwrap_or(mid_f), + _ => mid_f, + }; + if effective_px > 0.0 { + let notional = size_f * effective_px; + if notional < 10.0 { + if args.r#type == "market" { + let sz_factor = 10_f64.powi(sz_decimals as i32); + let min_size = (10.0 / effective_px * sz_factor).ceil() / sz_factor; + eprintln!( + "[auto-adjust] size {} → {} to meet $10 minimum spot notional (${:.2} → ${:.2})", + args.size, min_size, notional, min_size * effective_px, + ); + // Format with sz_decimals to avoid trailing FP noise + size_str_owned = format!("{:.*}", sz_decimals as usize, min_size); + } else { + println!("{}", super::error_response( + &format!( + "Order value {:.4} USDC is below Hyperliquid's 10 USDC minimum for spot orders. \ + Increase --size or --price (current: {} × {} = {:.4} USDC).", + notional, args.size, effective_px, notional + ), + "ORDER_BELOW_MIN_NOTIONAL", + "Increase --size or --price so the order value is at least 10 USDC." + )); + return Ok(()); + } + } + } + } + let size_str: &str = &size_str_owned; + // Compute slippage price using configurable tolerance (default 5%) let multiplier = if is_buy { 1.0 + args.slippage / 100.0 } else { 1.0 - args.slippage / 100.0 }; let slippage_px_str = round_px(mid_f * multiplier, sz_decimals); // Spot orders always have reduce_only = false (no position to reduce) let action = match args.r#type.as_str() { - "market" => build_market_order_action(asset_idx, is_buy, &args.size, false, &slippage_px_str), + "market" => build_market_order_action(asset_idx, is_buy, size_str, false, &slippage_px_str), "limit" => { let price_str = match args.price.as_deref() { Some(p) => p, @@ -142,7 +169,7 @@ pub async fn run(args: SpotOrderArgs) -> anyhow::Result<()> { return Ok(()); } let tif = if args.post_only { "Alo" } else { "Gtc" }; - build_limit_order_action(asset_idx, is_buy, price_str, &args.size, false, tif) + build_limit_order_action(asset_idx, is_buy, price_str, size_str, false, tif) } _ => { println!("{}", super::error_response(&format!("Unknown order type '{}'", args.r#type), "INVALID_ARGUMENT", "Use --type market or --type limit.")); @@ -159,7 +186,7 @@ pub async fn run(args: SpotOrderArgs) -> anyhow::Result<()> { "pair": format!("{}/USDC", coin), "assetIndex": asset_idx, "side": args.side, - "size": args.size, + "size": size_str, "type": args.r#type, "price": args.price, "currentMidPrice": current_price, @@ -212,7 +239,7 @@ pub async fn run(args: SpotOrderArgs) -> anyhow::Result<()> { "market": "spot", "coin": coin, "side": args.side, - "size": args.size, + "size": size_str, "type": args.r#type, "price": args.price, "result": result diff --git a/skills/hyperliquid-plugin/src/commands/spot_prices.rs b/skills/hyperliquid-plugin/src/commands/spot_prices.rs index ec5ef8dff..43f81fe4e 100644 --- a/skills/hyperliquid-plugin/src/commands/spot_prices.rs +++ b/skills/hyperliquid-plugin/src/commands/spot_prices.rs @@ -5,8 +5,9 @@ use crate::config::info_url; #[derive(Args)] pub struct SpotPricesArgs { /// Show price for a specific token (e.g. PURR, HYPE). - /// Omit to list all spot markets. - #[arg(long)] + /// Omit to list all spot markets. Accepts `--coin` as an alias for parity + /// with `spot-order` and `markets` parameter naming. + #[arg(long, alias = "coin")] pub token: Option, /// Only show canonical markets (filters out non-canonical @N markets with no readable name) diff --git a/skills/hyperliquid-plugin/src/signing.rs b/skills/hyperliquid-plugin/src/signing.rs index 4949c8a40..e51e61708 100644 --- a/skills/hyperliquid-plugin/src/signing.rs +++ b/skills/hyperliquid-plugin/src/signing.rs @@ -15,21 +15,41 @@ pub fn format_px(px: f64) -> String { s.to_string() } -/// Round a price to the correct number of decimal places for a given coin, -/// matching the Python SDK's round_to_sz_decimals(px, sz_decimals) logic. -/// This rounds to `sz_decimals` significant figures. +/// Round a price to HL's allowed precision. /// -/// Example: ETH sz_decimals=4, price=2098.4 → 4 sig figs → round to 0 dp → "2098" +/// HL spec: prices can have **up to 5 significant figures**, AND **no more +/// than (MAX_DECIMALS - sz_decimals) decimal places** where MAX_DECIMALS is 6 +/// for perps. (Spot is 8, so passing perp-rounded prices through to spot is +/// always safe.) +/// +/// The previous implementation used `sig_figs = sz_decimals` which silently +/// over-rounded high-priced HIP-3 markets — e.g. NVDA sz_decimals=3, mid +/// 217.495 → 3 sig figs → "217" (an integer), losing 2pp of risk-management +/// precision in TP/SL bracket prices. With 5 sig figs and the decimal-place +/// cap, NVDA 217.495 keeps as "217.5", and user inputs like 212.06 stay as +/// 212.06. +/// +/// Examples (sig_figs=5, MAX=6): +/// ETH sz=4 px=2098.4 → 5sf,1dp → "2098.4" +/// NVDA sz=3 px=217.495 → 5sf,2dp → "217.5" +/// NVDA sz=3 px=212.06 → 5sf,2dp → "212.06" +/// BTC sz=5 px=93246.7 → 5sf,1dp → "93247" (cap=1 wins over 5sf which→0dp) +/// BIO sz=0 px=0.032 → 5sf,6dp → "0.032" pub fn round_px(px: f64, sz_decimals: u32) -> String { if px == 0.0 { return "0".to_string(); } - // Match Python SDK: f"{px:.{sz_decimals}g}" uses at minimum 1 significant figure. - // Without this, coins with sz_decimals=0 and price < 0.5 (e.g. BIO at $0.032) - // produce price="0" via px.round() → HL rejects with "Order has invalid price." - let sig_figs = sz_decimals.max(1); + const HL_PERP_MAX_DECIMALS: i32 = 6; + const SIG_FIGS: i32 = 5; + let mag = px.abs().log10().floor() as i32; - let decimal_places = (sig_figs as i32) - mag - 1; + // 5 sig-figs decimal places: positive when |px| < 10^5, negative for big + // numbers that need rounding to higher integer multiples. + let sf_dp = SIG_FIGS - mag - 1; + // HL hard cap on decimal places. + let cap_dp = HL_PERP_MAX_DECIMALS - (sz_decimals as i32); + let decimal_places = sf_dp.min(cap_dp); + let rounded = if decimal_places <= 0 { let factor = 10_f64.powi(-decimal_places); (px / factor).round() * factor @@ -37,9 +57,6 @@ pub fn round_px(px: f64, sz_decimals: u32) -> String { let factor = 10_f64.powi(decimal_places); (px * factor).round() / factor }; - // For integer results (decimal_places ≤ 0), return as-is — no trimming to avoid - // stripping significant zeros (e.g. "2100" → "21" if trimmed). - // For decimal results, trim trailing zeros after the decimal point. if decimal_places <= 0 { format!("{:.0}", rounded) } else {