Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Trade perpetual futures and spot tokens on Hyperliquid DEX with 18 tools for orders, positions, funding, and USDC transfers.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
client.py
1"""2Hyperliquid API Client — wraps /info (read-only) and /exchange (signed) endpoints.34Uses the hyperliquid-python-sdk Info class for reads, and custom signing5(via wallet service) for exchange actions.6"""78import logging9import os10import time11from decimal import Decimal12from typing import Any, Dict, Optional1314import aiohttp1516from .signing import sign_l1_action, sign_user_action, sign_user_set_abstraction1718logger = logging.getLogger(__name__)1920DEFAULT_API_URL = "https://api.hyperliquid.xyz"21DEFAULT_SLIPPAGE = 0.03 # 3% for market orders222324def float_to_wire(x: float) -> str:25"""Convert a float to a string matching Hyperliquid's server-side normalization.2627Matches the official Hyperliquid Python SDK: rounds to 8 decimal places,28then strips trailing zeros via Decimal.normalize().29"""30rounded = f"{x:.8f}"31if abs(float(rounded) - x) >= 1e-12:32raise ValueError(f"float_to_wire: rounding loses precision for {x}")33if rounded == "-0.00000000":34rounded = "0.00000000"35normalized = Decimal(rounded).normalize()36return f"{normalized:f}"3738# Hyperliquid bridge contract (for USDC deposits)39BRIDGE_ADDRESS = "0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7"4041# USDC on Arbitrum42USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"43USDC_DECIMALS = 644ARBITRUM_CHAIN_ID = 4216145MIN_DEPOSIT_USDC = 5464748class HyperliquidClient:49"""50Async Hyperliquid client.5152- Info methods: POST to /info (no auth)53- Exchange methods: POST to /exchange (signed)54"""5556def __init__(self, api_url: Optional[str] = None):57self.api_url = api_url or os.environ.get(58"HYPERLIQUID_API_URL", DEFAULT_API_URL59)60# Cached metadata61self._meta: Optional[dict] = None62self._spot_meta: Optional[dict] = None63self._name_to_index: Dict[str, int] = {}64self._spot_name_to_index: Dict[str, int] = {}65self._spot_mid_key: Dict[str, str] = {} # user name → allMids key66self._sz_decimals: Dict[str, int] = {}67# Builder dex (HIP-3) metadata — lazy loaded68self._perp_dexs: Optional[list] = None # perpDexs API response69self._dex_offsets: Dict[str, int] = {} # dex_name -> asset index offset70self._builder_meta: Dict[str, dict] = {} # dex_name -> meta response7172# ── Internal helpers ─────────────────────────────────────────────────7374async def _post(self, endpoint: str, payload: dict) -> Any:75"""POST to Hyperliquid API."""76url = f"{self.api_url}{endpoint}"77async with aiohttp.ClientSession() as session:78async with session.post(79url,80json=payload,81timeout=aiohttp.ClientTimeout(total=15),82) as resp:83if resp.status >= 400:84body = await resp.text()85raise Exception(f"Hyperliquid API {resp.status}: {body}")86data = await resp.json()87# Hyperliquid returns HTTP 200 with {"status": "err", "response": "..."} for API-level errors88if isinstance(data, dict) and data.get("status") == "err":89raise Exception(f"Hyperliquid error: {data.get('response', data)}")90return data9192async def _info(self, req_type: str, **kwargs) -> Any:93"""Query the /info endpoint."""94payload = {"type": req_type, **kwargs}95return await self._post("/info", payload)9697async def _exchange(98self,99action: dict,100nonce: Optional[int] = None,101vault_address: Optional[str] = None,102) -> Any:103"""Submit a signed action to the /exchange endpoint.104105Matches SDK's Exchange._post_action() behavior.106"""107if nonce is None:108nonce = int(time.time() * 1000)109110action_type = action.get("type", "unknown")111wallet_addr = self._cached_address or "unknown"112logger.info(113f"_exchange: action_type={action_type}, wallet={wallet_addr}, nonce={nonce}"114)115116signature = await sign_l1_action(action, nonce, vault_address)117118# Match SDK payload structure exactly119# vaultAddress: included for most actions, None for usdClassTransfer/sendAsset120# expiresAfter: None by default (matches SDK's expires_after = None)121payload = {122"action": action,123"nonce": nonce,124"signature": signature,125"vaultAddress": vault_address if action_type not in ["usdClassTransfer", "sendAsset"] else None,126"expiresAfter": None, # matches SDK default127}128129try:130return await self._post("/exchange", payload)131except Exception as e:132logger.error(133f"_exchange FAILED: action_type={action_type}, wallet={wallet_addr}, "134f"nonce={nonce}, error={e}"135)136raise137138async def _ensure_meta(self) -> None:139"""Fetch and cache perp + spot metadata for asset index resolution."""140if self._meta is None:141self._meta = await self._info("meta")142for i, asset in enumerate(self._meta.get("universe", [])):143name = asset["name"]144self._name_to_index[name] = i145self._sz_decimals[name] = asset.get("szDecimals", 0)146147if self._spot_meta is None:148try:149self._spot_meta = await self._info("spotMeta")150for pair in self._spot_meta.get("universe", []):151# universe entries: {"tokens": [1,0], "name": "PURR/USDC", "index": 0}152# Non-canonical entries have names like "@1", "@2"153pair_name = pair.get("name", "")154idx = pair.get("index", 0)155if "/" in pair_name:156# Canonical pair like "PURR/USDC" — map base name157base = pair_name.split("/")[0]158self._spot_name_to_index[base] = idx159self._spot_mid_key[base] = pair_name # allMids uses "PURR/USDC"160elif pair_name:161# Non-canonical like "@1" — map as-is162self._spot_name_to_index[pair_name] = idx163self._spot_mid_key[pair_name] = pair_name164except Exception:165self._spot_meta = {}166167async def _ensure_builder_dex(self, dex_name: str) -> None:168"""Lazy-load builder dex metadata (HIP-3 perps like xyz:NVDA).169170Called only when a coin with ':' prefix is encountered.171Fetches perpDexs once to discover all builder dexes and compute offsets,172then fetches meta for the specific builder dex.173"""174# Already loaded this dex175if dex_name in self._builder_meta:176return177178# Fetch perpDexs list once to compute offsets179if self._perp_dexs is None:180raw = await self._info("perpDexs")181# perpDexs returns [null, {"name": "xyz", ...}, {"name": "rage", ...}, ...]182# First element is null (default dex), rest are builder dex objects.183# Offset formula matches SDK: skip index 0, then 110000 + i * 10000184self._perp_dexs = raw185idx = 0186for entry in raw:187if entry is None:188continue189name = entry if isinstance(entry, str) else entry.get("name", "")190if name:191self._dex_offsets[name] = 110000 + idx * 10000192idx += 1193194if dex_name not in self._dex_offsets:195raise ValueError(196f"Unknown builder dex: {dex_name}. "197f"Available: {list(self._dex_offsets.keys())}"198)199200# Fetch meta for this specific builder dex201meta = await self._info("meta", dex=dex_name)202self._builder_meta[dex_name] = meta203offset = self._dex_offsets[dex_name]204205for i, asset in enumerate(meta.get("universe", [])):206asset_name = asset["name"]207# meta(dex="xyz") returns names already prefixed: "xyz:NVDA"208# Use the name as-is if it contains ':', otherwise prefix it209full_name = asset_name if ":" in asset_name else f"{dex_name}:{asset_name}"210self._name_to_index[full_name] = offset + i211self._sz_decimals[full_name] = asset.get("szDecimals", 0)212213async def _resolve_asset(self, coin: str) -> int:214"""Resolve coin name to perp asset index.215216Supports builder perps via 'dex:COIN' format (e.g. 'xyz:NVDA').217"""218# Try direct match first219idx = self._name_to_index.get(coin)220if idx is not None:221return idx222223# Case-insensitive fallback (e.g. "ai16z" -> "AI16Z")224coin_upper = coin.upper()225for name, i in self._name_to_index.items():226if name.upper() == coin_upper:227return i228229# If coin contains ':', try loading the builder dex230if ":" in coin:231dex_name = coin.split(":")[0]232await self._ensure_builder_dex(dex_name)233idx = self._name_to_index.get(coin)234if idx is not None:235return idx236237raise ValueError(238f"Unknown perp asset: {coin}. "239f"Available: {list(self._name_to_index.keys())[:20]}..."240)241242async def _resolve_any_asset(self, coin: str) -> int:243"""Resolve coin name to asset index — tries perps, builder perps, then spot."""244await self._ensure_meta()245try:246return await self._resolve_asset(coin)247except ValueError:248pass249# Try spot (exact then case-insensitive)250idx = self._spot_name_to_index.get(coin)251if idx is not None:252return 10000 + idx253coin_upper = coin.upper()254for name, i in self._spot_name_to_index.items():255if name.upper() == coin_upper:256return 10000 + i257raise ValueError(f"Unknown asset: {coin}")258259def _resolve_spot_asset(self, coin: str) -> int:260"""Resolve coin name to spot asset index (10000 + idx)."""261idx = self._spot_name_to_index.get(coin)262if idx is None:263raise ValueError(264f"Unknown spot asset: {coin}. "265f"Available: {list(self._spot_name_to_index.keys())[:20]}..."266)267return 10000 + idx268269def _format_size(self, coin: str, size: float) -> str:270"""Format size using float_to_wire for exact Hyperliquid normalization."""271return float_to_wire(size)272273def _format_price(self, price: float, coin: str = "", is_spot: bool = False) -> str:274"""Format price matching the official SDK's rounding rules.275276Prices can have up to 5 significant figures, but no more than277(MAX_DECIMALS - szDecimals) decimal places where MAX_DECIMALS is 6278for perps and 8 for spot. Integer prices are always allowed.279"""280# Step 1: round to 5 significant figures281rounded = float(f"{price:.5g}")282# Step 2: constrain decimal places based on szDecimals283sz_decimals = self._sz_decimals.get(coin, 0)284max_decimals = 8 if is_spot else 6285max_dp = max_decimals - sz_decimals286if max_dp >= 0:287rounded = round(rounded, max_dp)288return float_to_wire(rounded)289290async def _validate_order_margin(291self,292coin: str,293size: float,294price: float,295leverage: int = 1,296is_spot: bool = False,297) -> tuple[bool, str]:298"""Validate if account has sufficient margin for an order.299300Returns: (is_valid, error_message)301"""302try:303# Spot orders don't use leverage/margin304if is_spot:305return (True, "")306307# Calculate order notional value308order_value = size * price309310# Check minimum order value ($10)311if order_value < 10:312return (313False,314f"Order value ${order_value:.2f} is below minimum $10. "315f"Increase size to at least {10 / price:.4f} {coin}",316)317318# Calculate required margin (notional / leverage)319required_margin = order_value / max(leverage, 1)320321# CRITICAL: Check abstraction mode FIRST to determine which account to query322# Unified account mode keeps funds in SPOT, not PERP323address = await self._get_address()324current_mode = "default"325try:326abstraction_state = await self.get_user_abstraction_state(address)327if isinstance(abstraction_state, str):328current_mode = abstraction_state329elif isinstance(abstraction_state, dict):330current_mode = abstraction_state.get("type", abstraction_state.get("state", "default"))331except Exception:332current_mode = "default"333334# Get account state based on abstraction mode335if current_mode == "unifiedAccount":336# Unified account: funds are in SPOT (shared collateral)337spot_state = await self.get_spot_state(address)338# Find USDC balance in spot339usdc_balance = 0.0340for bal in spot_state.get("balances", []):341if bal.get("coin") == "USDC":342usdc_balance = float(bal.get("total", 0))343break344345# Use SPOT balance as available margin346account_value = usdc_balance347total_margin_used = 0.0 # Unified account shares collateral348available_margin = account_value349350logger.info(351f"Margin validation for {coin} (UNIFIED ACCOUNT): "352f"spot_usdc=${usdc_balance:.2f}, "353f"available=${available_margin:.2f}"354)355else:356# Default/disabled mode: check PERP account357state = await self.get_account_state(address)358margin_summary = state.get("marginSummary", {})359account_value = float(margin_summary.get("accountValue", 0))360total_margin_used = float(margin_summary.get("totalMarginUsed", 0))361available_margin = account_value - total_margin_used362363logger.info(364f"Margin validation for {coin} (PERP ACCOUNT): "365f"accountValue=${account_value:.2f}, "366f"totalMarginUsed=${total_margin_used:.2f}, "367f"available=${available_margin:.2f}"368)369370# Require a safety buffer (5%)371safe_available = available_margin * 0.95372373if required_margin > safe_available:374error_msg = (375f"Insufficient margin for {coin} order. "376f"Required: ${required_margin:.2f}, "377f"Available: ${safe_available:.2f} "378f"(Account value: ${account_value:.2f}, Used: ${total_margin_used:.2f}). "379)380381# Add mode-specific guidance382if current_mode == "unifiedAccount":383error_msg += (384f"\n\n⚠️ UNIFIED ACCOUNT MODE ACTIVE: Checked SPOT balance (${account_value:.2f}) where your collateral is. "385f"If you think you have more funds, use `hl_total_balance` tool to see the complete picture. "386f"DO NOT use `hl_account` or `hl_balances` alone - they only show partial balances!"387)388elif account_value == 0:389error_msg += (390"\n\n💡 NOTE: Showing $0 balance in PERP account."391" If you have funds in SPOT, enable unified"392" account mode to share collateral across"393" spot/perp, or manually transfer USDC to"394" perp using `hl_transfer_usd`."395)396397if ":" in coin:398# Builder perp specific guidance399dex_name = coin.split(":")[0]400error_msg += (401f"\n\n🏗️ BUILDER PERP ({dex_name}): "402f"DEX abstraction auto-transfers funds from main account → {dex_name} dex when order is placed. "403f"Validation checks main account available margin (not {dex_name} dex balance)."404)405406error_msg += (407f"\n\n✅ SOLUTIONS:\n"408f"1) Use `hl_total_balance` to check actual available funds\n"409f"2) Reduce size to {safe_available * leverage / price:.4f} {coin} or less\n"410f"3) Increase leverage (if below max for this asset)\n"411f"4) Deposit more USDC to your Hyperliquid account\n"412f"5) Close other positions to free up margin"413)414415return (False, error_msg)416417return (True, "")418419except Exception as e:420logger.warning(f"Order validation failed: {e}")421# Don't block order on validation errors - let exchange validate422return (True, "")423424# ── Info Methods (read-only, no auth) ────────────────────────────────425426async def get_account_state(self, address: str, dex: Optional[str] = None) -> dict:427"""Get perp positions, margin, account value.428429Args:430address: Wallet address431dex: Builder dex name (e.g. 'xyz') for HIP-3 perp positions.432None for default perp positions.433"""434if dex:435return await self._info("clearinghouseState", user=address, dex=dex)436return await self._info("clearinghouseState", user=address)437438async def get_spot_state(self, address: str) -> dict:439"""Get spot token balances."""440return await self._info("spotClearinghouseState", user=address)441442async def get_open_orders(self, address: str) -> list:443"""Get all open orders."""444return await self._info("openOrders", user=address)445446async def get_all_mids(self, dex: Optional[str] = None) -> dict:447"""Get current mid prices for all assets.448449Args:450dex: Builder dex name (e.g. 'xyz') for builder perp mid prices.451None for default perp/spot mid prices.452"""453if dex:454return await self._info("allMids", dex=dex)455return await self._info("allMids")456457async def get_l2_book(self, coin: str) -> dict:458"""Get L2 orderbook snapshot."""459return await self._info("l2Book", coin=coin)460461async def get_meta(self) -> dict:462"""Get perp universe metadata."""463await self._ensure_meta()464return self._meta465466async def get_spot_meta(self) -> dict:467"""Get spot universe metadata."""468await self._ensure_meta()469return self._spot_meta470471async def get_candles(472self, coin: str, interval: str, start: int, end: int473) -> list:474"""Get OHLCV candlestick data."""475return await self._info(476"candleSnapshot",477req={"coin": coin, "interval": interval, "startTime": start, "endTime": end},478)479480async def get_user_fills(self, address: str) -> list:481"""Get recent trade fills."""482return await self._info("userFills", user=address)483484async def get_funding_history(485self, coin: str, start: int486) -> list:487"""Get historical funding rates."""488return await self._info(489"fundingHistory", coin=coin, startTime=start490)491492async def get_predicted_fundings(self) -> list:493"""Get predicted next funding rates for all assets."""494return await self._info("predictedFundings")495496async def get_user_fees(self, address: str) -> dict:497"""Get user fee schedule."""498return await self._info("userFees", user=address)499500async def get_user_abstraction_state(self, address: str) -> dict:501"""Query current abstraction state for a user.502503Returns abstraction mode: "default", "unifiedAccount", "portfolioMargin", or "disabled"504"""505return await self._info("userAbstraction", user=address)506507async def get_order_status(self, address: str, oid: int) -> dict:508"""Look up a single order by oid."""509return await self._info("orderStatus", user=address, oid=oid)510511# ── Exchange Methods (require signing) ───────────────────────────────512513async def place_order(514self,515coin: str,516is_buy: bool,517size: float,518price: Optional[float] = None,519order_type: str = "limit",520reduce_only: bool = False,521cloid: Optional[str] = None,522is_spot: bool = False,523trigger_px: Optional[float] = None,524tpsl: Optional[str] = None,525) -> dict:526"""527Place a perp or spot order.528529Args:530coin: Asset name (e.g. "BTC", "ETH")531is_buy: True for buy, False for sell532size: Order size in base asset533price: Limit price. None = market order (IoC with slippage)534order_type: "limit" (GTC), "ioc", "alo" (post-only)535reduce_only: If True, only reduces position536cloid: Optional client order ID537is_spot: If True, use spot asset index538trigger_px: Trigger price for stop loss / take profit orders539tpsl: "tp" for take profit, "sl" for stop loss540"""541await self._ensure_meta()542543# Builder perps (HIP-3) need DEX abstraction for collateral544# Enable BEFORE validation so we can check actual available margin545logger.info(f"place_order DEX check: coin={coin}, is_spot={is_spot}, has_colon={':' in coin}")546if not is_spot and ":" in coin:547logger.info(f"place_order: triggering DEX abstraction for builder perp {coin}")548await self.ensure_dex_abstraction()549else:550logger.info(f"place_order: skipping DEX abstraction (is_spot={is_spot}, coin={coin})")551552asset_idx = (553self._resolve_spot_asset(coin)554if is_spot555else await self._resolve_asset(coin)556)557558# Market order: fetch mid price and apply slippage559# IMPORTANT: Skip this for trigger orders - they use trigger_px, not current mid560if price is None and trigger_px is None:561# Regular market order (non-trigger)562# Builder perps use dex-specific allMids563if not is_spot and ":" in coin:564dex_name = coin.split(":")[0]565mids = await self.get_all_mids(dex=dex_name)566# allMids(dex="xyz") returns keys like "xyz:NVDA", use full coin name567mid_key = coin568else:569mids = await self.get_all_mids()570# Spot uses pair name in allMids (e.g. "PURR/USDC" or "@1")571if is_spot:572mid_key = self._spot_mid_key.get(coin, coin)573else:574mid_key = coin575mid_str = mids.get(mid_key)576if not mid_str:577raise ValueError(f"No mid price for {coin} (looked up '{mid_key}')")578mid = float(mid_str)579slippage = DEFAULT_SLIPPAGE580price = mid * (1 + slippage) if is_buy else mid * (1 - slippage)581order_type = "ioc" # Force IoC for market orders582583# Pre-flight validation: check margin requirements584# CRITICAL: When unified account is active, funds are shared across spot/perp/builder-dexes585# Check SPOT balance instead of perp, since that's where the collateral actually is586if not is_spot and not reduce_only:587try:588address = await self._get_address()589590# Check if unified account is active591try:592abstraction_state = await self.get_user_abstraction_state(address)593if isinstance(abstraction_state, str):594current_abstraction = abstraction_state595elif isinstance(abstraction_state, dict):596current_abstraction = abstraction_state.get("type", abstraction_state.get("state", "default"))597else:598current_abstraction = "default"599except Exception:600current_abstraction = "default"601602# Get margin state - check SPOT if unified, otherwise check perp603if current_abstraction == "unifiedAccount":604# Unified account: check SPOT balance (where collateral actually is)605spot_state = await self.get_spot_state(address)606# Find USDC balance in spot607usdc_balance = 0.0608for bal in spot_state.get("balances", []):609if bal.get("coin") == "USDC":610usdc_balance = float(bal.get("total", 0))611break612613# Create a synthetic margin summary from spot balance614state = {615"marginSummary": {616"accountValue": str(usdc_balance),617"totalMarginUsed": "0.0", # Unified account shares collateral618"totalNtlPos": "0.0",619"totalRawUsd": str(usdc_balance),620},621"assetPositions": [], # Will check metadata for leverage622}623logger.info(f"Unified account active: using SPOT balance ${usdc_balance:.2f} for margin validation")624else:625# Default mode: check perp clearinghouse626state = await self.get_account_state(address)627628# Extract leverage from assetPositions or use default629leverage = 1630for pos in state.get("assetPositions", []):631position = pos.get("position", pos)632if position.get("coin") == coin:633lev_info = position.get("leverage", {})634leverage = lev_info.get("value", 1)635break636637# If no position, check metadata for default/max leverage638if leverage == 1:639if ":" in coin:640dex_name = coin.split(":")[0]641meta = self._builder_meta.get(dex_name, {})642for asset in meta.get("universe", []):643if asset["name"] == coin or asset["name"] == coin.split(":", 1)[-1]:644# Use max leverage as estimate if no position exists645leverage = asset.get("maxLeverage", 1)646break647else:648if self._meta:649for asset in self._meta.get("universe", []):650if asset["name"] == coin:651leverage = asset.get("maxLeverage", 1)652break653654# Validate margin655is_valid, error_msg = await self._validate_order_margin(656coin=coin,657size=size,658price=price,659leverage=leverage,660is_spot=is_spot,661)662663if not is_valid:664raise ValueError(error_msg)665666except ValueError:667# Re-raise validation errors668raise669except Exception as e:670# Don't block order on validation errors671logger.warning(f"Order validation error (non-blocking): {e}")672673# Build order type spec674if trigger_px is not None:675# Trigger order - use float_to_wire directly (matches SDK)676# This ensures consistent serialization when price == trigger_px677formatted_trigger = float_to_wire(trigger_px)678formatted_price = float_to_wire(price)679680is_market_trigger = (price == trigger_px) if price is not None else True681# CRITICAL: Field order must match SDK exactly for msgpack hash consistency682# SDK uses: isMarket, triggerPx, tpsl (not triggerPx first!)683trigger_spec = {684"isMarket": is_market_trigger,685"triggerPx": formatted_trigger,686"tpsl": tpsl or "tp",687}688tif_spec = {"trigger": trigger_spec}689elif order_type == "limit":690# Regular orders - keep existing validation691formatted_price = self._format_price(price, coin=coin, is_spot=is_spot)692tif_spec = {"limit": {"tif": "Gtc"}}693elif order_type == "ioc":694formatted_price = self._format_price(price, coin=coin, is_spot=is_spot)695tif_spec = {"limit": {"tif": "Ioc"}}696elif order_type == "alo":697formatted_price = self._format_price(price, coin=coin, is_spot=is_spot)698tif_spec = {"limit": {"tif": "Alo"}}699else:700formatted_price = self._format_price(price, coin=coin, is_spot=is_spot)701tif_spec = {"limit": {"tif": "Gtc"}}702703order = {704"a": asset_idx,705"b": is_buy,706"p": formatted_price,707"s": self._format_size(coin, size),708"r": reduce_only,709"t": tif_spec,710}711if cloid:712order["c"] = cloid713714action = {715"type": "order",716"orders": [order],717"grouping": "na",718}719720logger.info(721f"place_order: coin={coin}, is_buy={is_buy}, size={size}, "722f"price={formatted_price}, order_type={order_type}, "723f"asset_idx={asset_idx}"724)725726# DEBUG: Log the full action structure for trigger orders727if trigger_px is not None:728import json729logger.info(f"TRIGGER ORDER ACTION: {json.dumps(action, indent=2)}")730731result = await self._exchange(action)732logger.info(f"place_order result: {result}")733return result734735async def cancel_order(self, coin: str, oid: int) -> dict:736"""Cancel an order by oid."""737await self._ensure_meta()738asset_idx = await self._resolve_any_asset(coin)739740action = {741"type": "cancel",742"cancels": [{"a": asset_idx, "o": oid}],743}744return await self._exchange(action)745746async def cancel_by_cloid(self, coin: str, cloid: str) -> dict:747"""Cancel an order by client order ID."""748await self._ensure_meta()749asset_idx = await self._resolve_any_asset(coin)750751action = {752"type": "cancelByCloid",753"cancels": [{"asset": asset_idx, "cloid": cloid}],754}755return await self._exchange(action)756757async def cancel_all(self, coin: Optional[str] = None) -> list:758"""759Cancel all open orders, optionally filtered by coin.760Returns list of cancel results.761"""762# No batch cancel action in HL — cancel individually763address = await self._get_address()764orders = await self.get_open_orders(address)765766if coin:767orders = [o for o in orders if o.get("coin") == coin]768769results = []770for order in orders:771try:772r = await self.cancel_order(773order["coin"], order["oid"]774)775results.append(r)776except Exception as e:777results.append({"error": str(e), "oid": order.get("oid")})778779return results780781async def modify_order(782self,783oid: int,784coin: str,785is_buy: bool,786size: float,787price: float,788order_type: str = "limit",789) -> dict:790"""Modify an existing order."""791await self._ensure_meta()792asset_idx = await self._resolve_any_asset(coin)793794if order_type == "limit":795tif_spec = {"limit": {"tif": "Gtc"}}796elif order_type == "ioc":797tif_spec = {"limit": {"tif": "Ioc"}}798elif order_type == "alo":799tif_spec = {"limit": {"tif": "Alo"}}800else:801tif_spec = {"limit": {"tif": "Gtc"}}802803action = {804"type": "modify",805"oid": oid,806"order": {807"a": asset_idx,808"b": is_buy,809"p": self._format_price(price, coin=coin),810"s": self._format_size(coin, size),811"r": False,812"t": tif_spec,813},814}815return await self._exchange(action)816817# ── DEX Abstraction (HIP-3 builder perps collateral) ─────────────────818819_dex_abstraction_enabled: bool = False820821async def ensure_dex_abstraction(self) -> dict:822"""Enable DEX abstraction so collateral auto-transfers to builder dexes.823824Required for trading HIP-3 builder perps (xyz:NVDA, xyz:TSLA, etc.).825Without this, the wallet has no collateral in builder dex clearinghouses826and all orders fail with "Insufficient margin".827828Checks current abstraction state first, then tries multiple methods:8291. agentSetAbstraction("u") - modern agent-signed approach8302. agentEnableDexAbstraction - legacy agent-signed approach8313. userSetAbstraction("unifiedAccount") - user-signed fallback832833Safe to call multiple times — Hyperliquid handles idempotency.834"""835address = await self._get_address()836837# ALWAYS check current state first - don't trust cache838# The cache might be stale if the state was changed externally or if enable failed839try:840state_result = await self.get_user_abstraction_state(address)841# API may return string ("default") or dict ({"type": "default"})842if isinstance(state_result, str):843current_state = state_result844elif isinstance(state_result, dict):845current_state = state_result.get("type", state_result.get("state", "unknown"))846else:847current_state = "unknown"848849logger.info(f"DEX abstraction: current state for {address} = {current_state}")850851# If already in unifiedAccount mode, we're done852if current_state == "unifiedAccount":853logger.info("DEX abstraction: already enabled (unifiedAccount mode)")854self._dex_abstraction_enabled = True855return {"status": "already_enabled", "state": current_state}856857# If cache says enabled but state check shows otherwise, reset cache858if self._dex_abstraction_enabled and current_state != "unifiedAccount":859logger.warning(860f"DEX abstraction: cache says enabled but state is '{current_state}'. "861f"Resetting cache and retrying enable."862)863self._dex_abstraction_enabled = False864865# SDK comment says: "the account must be in 'default' mode to succeed"866if current_state not in ("default", "disabled"):867logger.warning(868f"DEX abstraction: account in {current_state} mode. "869f"Transitions may fail if not in 'default' mode."870)871except Exception as e:872logger.warning(f"DEX abstraction: failed to query current state: {e}")873# Continue anyway, state check is informational874875# Try Method 1: agentSetAbstraction with "u" (unified account)876logger.info(f"DEX abstraction: trying agentSetAbstraction(u) for {address}")877action = {878"type": "agentSetAbstraction",879"abstraction": "u", # "u" = unified account (DEX abstraction enabled)880}881882try:883result = await self._exchange(action, vault_address=None)884logger.info(f"DEX abstraction: agentSetAbstraction result = {result}")885self._dex_abstraction_enabled = True886return result887except Exception as e:888err_str = str(e).lower()889logger.warning(f"DEX abstraction: agentSetAbstraction failed: {e}")890891# If it says "already" or "enabled", that's success892if "already" in err_str or "enabled" in err_str:893self._dex_abstraction_enabled = True894return {"status": "ok", "detail": str(e)}895896# Try Method 2: agentEnableDexAbstraction (legacy)897logger.info(f"DEX abstraction: trying agentEnableDexAbstraction for {address}")898action = {899"type": "agentEnableDexAbstraction",900}901902try:903result = await self._exchange(action, vault_address=None)904logger.info(f"DEX abstraction: agentEnableDexAbstraction result = {result}")905self._dex_abstraction_enabled = True906return result907except Exception as e:908err_str = str(e).lower()909logger.warning(f"DEX abstraction: agentEnableDexAbstraction failed: {e}")910911if "already" in err_str or "enabled" in err_str:912self._dex_abstraction_enabled = True913return {"status": "ok", "detail": str(e)}914915# Try Method 3: userSetAbstraction (user-signed, not agent-signed)916# This uses a different signing domain (HyperliquidSignTransaction vs Agent)917# SDK example shows this works when account is in "default" mode918logger.info(f"DEX abstraction: trying userSetAbstraction(unifiedAccount) for {address}")919920try:921result = await self._user_set_abstraction(address, "unifiedAccount")922logger.info(f"DEX abstraction: userSetAbstraction result = {result}")923924# Verify the state actually changed925try:926new_state_result = await self.get_user_abstraction_state(address)927if isinstance(new_state_result, str):928new_state = new_state_result929elif isinstance(new_state_result, dict):930new_state = new_state_result.get("type", new_state_result.get("state", "unknown"))931else:932new_state = "unknown"933logger.info(f"DEX abstraction: state AFTER userSetAbstraction = {new_state}")934935if new_state == "unifiedAccount":936logger.info("DEX abstraction: successfully enabled (verified)")937self._dex_abstraction_enabled = True938return result939else:940logger.warning(941f"DEX abstraction: userSetAbstraction returned success but state is still '{new_state}', not 'unifiedAccount'. "942f"This may indicate the account has restrictions. Full result: {result}"943)944except Exception as verify_err:945logger.warning(f"DEX abstraction: failed to verify state after userSetAbstraction: {verify_err}")946947self._dex_abstraction_enabled = True948return result949except Exception as e:950err_str = str(e).lower()951logger.error(f"DEX abstraction: userSetAbstraction failed: {e}")952953if "already" in err_str or "enabled" in err_str:954self._dex_abstraction_enabled = True955return {"status": "ok", "detail": str(e)}956957# All methods failed958raise RuntimeError(959f"Failed to enable DEX abstraction for {address}. "960f"Your account may have DEX abstraction manually disabled. "961f"Please enable it at app.hyperliquid.xyz:\n"962f"1. Go to Settings (top right)\n"963f"2. Find 'Disable HIP-3 Dex Abstraction' checkbox\n"964f"3. UNCHECK it to enable DEX abstraction\n"965f"4. Then retry your order"966)967968async def _user_set_abstraction(self, user: str, abstraction: str) -> dict:969"""Set abstraction mode for user (user-signed action).970971Matches SDK's user_set_abstraction exactly.972973Args:974user: User wallet address975abstraction: "unifiedAccount", "portfolioMargin", or "disabled"976977Returns: API response978"""979nonce = int(time.time() * 1000)980981# Action payload must include signatureChainId and hyperliquidChain982# These are required for all user-signed actions983action = {984"type": "userSetAbstraction",985"user": user.lower(),986"abstraction": abstraction,987"nonce": nonce,988"signatureChainId": "0xa4b1", # 42161 in hex (Arbitrum mainnet)989"hyperliquidChain": "Mainnet",990}991992signature = await sign_user_set_abstraction(993user=user,994abstraction=abstraction,995nonce=nonce,996)997998payload = {999"action": action,1000"nonce": nonce,1001"signature": signature,1002}1003return await self._post("/exchange", payload)10041005async def update_leverage(1006self, coin: str, leverage: int, is_cross: bool = True1007) -> dict:1008"""Set leverage for a perp."""1009await self._ensure_meta()1010# Builder perps need DEX abstraction1011logger.info(f"update_leverage DEX check: coin={coin}, has_colon={':' in coin}")1012if ":" in coin:1013logger.info(f"update_leverage: triggering DEX abstraction for builder perp {coin}")1014await self.ensure_dex_abstraction()1015else:1016logger.info(f"update_leverage: skipping DEX abstraction for {coin}")1017asset_idx = await self._resolve_asset(coin)10181019action = {1020"type": "updateLeverage",1021"asset": asset_idx,1022"isCross": is_cross,1023"leverage": leverage,1024}1025return await self._exchange(action)10261027async def market_open(1028self,1029coin: str,1030is_buy: bool,1031size: float,1032slippage: float = DEFAULT_SLIPPAGE,1033is_spot: bool = False,1034) -> dict:1035"""Convenience: open a position with an IoC market order."""1036return await self.place_order(1037coin=coin,1038is_buy=is_buy,1039size=size,1040price=None, # triggers market order logic1041order_type="ioc",1042is_spot=is_spot,1043)10441045async def market_close(1046self, coin: str, address: str, slippage: float = DEFAULT_SLIPPAGE1047) -> dict:1048"""Close entire perp position for a coin."""1049# Builder perps (HIP-3) have separate clearinghouse state per dex1050if ":" in coin:1051dex_name = coin.split(":")[0]1052state = await self._info("clearinghouseState", user=address, dex=dex_name)1053else:1054state = await self.get_account_state(address)1055positions = state.get("assetPositions", [])10561057pos = None1058for p in positions:1059position = p.get("position", p)1060if position.get("coin") == coin:1061pos = position1062break10631064if not pos:1065raise ValueError(f"No open position for {coin}")10661067size = abs(float(pos["szi"]))1068is_buy = float(pos["szi"]) < 0 # Close short = buy, close long = sell10691070return await self.place_order(1071coin=coin,1072is_buy=is_buy,1073size=size,1074price=None,1075order_type="ioc",1076reduce_only=True,1077)10781079async def transfer_usd(1080self, amount: float, to_perp: bool = True1081) -> dict:1082"""1083Transfer USDC between spot and perp.1084Uses user-signed action (HyperliquidSignTransaction domain).1085"""1086address = await self._get_address()10871088# Query balances BEFORE transfer for verification1089try:1090perp_state_before = await self.get_account_state(address)1091spot_state_before = await self.get_spot_state(address)1092perp_value_before = float(perp_state_before.get("marginSummary", {}).get("accountValue", 0))1093spot_balances_before = spot_state_before.get("balances", [])1094logger.info(1095f"transfer_usd: BEFORE transfer - "1096f"perp_value=${perp_value_before:.2f}, "1097f"spot_balances={spot_balances_before}"1098)1099except Exception as e:1100logger.warning(f"transfer_usd: failed to query balances before transfer: {e}")11011102nonce = int(time.time() * 1000)11031104action = {1105"type": "usdClassTransfer",1106"amount": str(amount),1107"toPerp": to_perp,1108"nonce": nonce,1109"signatureChainId": "0xa4b1", # 42161 in hex (Arbitrum mainnet)1110"hyperliquidChain": "Mainnet",1111}11121113types = {1114"HyperliquidTransaction:UsdClassTransfer": [1115{"name": "hyperliquidChain", "type": "string"},1116{"name": "amount", "type": "string"},1117{"name": "toPerp", "type": "bool"},1118{"name": "nonce", "type": "uint64"},1119]1120}11211122signature = await sign_user_action(1123action={1124"hyperliquidChain": "Mainnet",1125"amount": str(amount),1126"toPerp": to_perp,1127"nonce": nonce,1128},1129types=types,1130primary_type="HyperliquidTransaction:UsdClassTransfer",1131)11321133payload = {1134"action": action,1135"nonce": nonce,1136"signature": signature,1137}11381139logger.info(f"transfer_usd: submitting ${amount} transfer ({'spot→perp' if to_perp else 'perp→spot'})")11401141try:1142result = await self._post("/exchange", payload)1143logger.info(f"transfer_usd: API response SUCCESS = {result}")1144except Exception as api_error:1145logger.error(f"transfer_usd: API call FAILED: {api_error}", exc_info=True)1146# Re-raise so caller sees the error1147raise11481149# Query balances AFTER transfer for verification1150try:1151perp_state_after = await self.get_account_state(address)1152spot_state_after = await self.get_spot_state(address)1153perp_value_after = float(perp_state_after.get("marginSummary", {}).get("accountValue", 0))1154spot_balances_after = spot_state_after.get("balances", [])1155logger.info(1156f"transfer_usd: AFTER transfer - "1157f"perp_value=${perp_value_after:.2f}, "1158f"spot_balances={spot_balances_after}"1159)1160except Exception as e:1161logger.warning(f"transfer_usd: failed to query balances after transfer: {e}")11621163return result11641165async def withdraw_from_bridge(1166self, amount: float, destination: Optional[str] = None1167) -> dict:1168"""1169Withdraw USDC from Hyperliquid to an Arbitrum wallet (L1 bridge withdrawal).11701171Fee: 1 USDC (deducted by Hyperliquid). Processing: ~5 minutes.11721173Args:1174amount: USDC amount to withdraw1175destination: Target wallet address (defaults to agent's own wallet)1176"""1177if not destination:1178destination = await self._get_address()11791180nonce = int(time.time() * 1000)11811182action = {1183"type": "withdraw3",1184"hyperliquidChain": "Mainnet",1185"signatureChainId": "0xa4b1", # 42161 in hex (Arbitrum mainnet)1186"destination": destination,1187"amount": str(amount),1188"time": nonce,1189}11901191types = {1192"HyperliquidTransaction:Withdraw": [1193{"name": "hyperliquidChain", "type": "string"},1194{"name": "destination", "type": "string"},1195{"name": "amount", "type": "string"},1196{"name": "time", "type": "uint64"},1197]1198}11991200signature = await sign_user_action(1201action={1202"hyperliquidChain": "Mainnet",1203"destination": destination,1204"amount": str(amount),1205"time": nonce,1206},1207types=types,1208primary_type="HyperliquidTransaction:Withdraw",1209)12101211payload = {1212"action": action,1213"nonce": nonce,1214"signature": signature,1215}1216return await self._post("/exchange", payload)12171218async def deposit_usdc(self, amount: float) -> dict:1219"""1220Deposit USDC from agent's Arbitrum wallet into Hyperliquid.12211222Sends an ERC-20 transfer of USDC to the Hyperliquid bridge contract.1223Minimum deposit: 5 USDC.12241225Args:1226amount: USDC amount to deposit (minimum 5)1227"""1228from eth_utils import keccak1229from core.wallet_runtime import wallet_request as _wallet_request, is_fly_machine as _is_fly_machine1230if not _is_fly_machine():1231raise RuntimeError("Not running on a Fly Machine — wallet unavailable")12321233if amount < MIN_DEPOSIT_USDC:1234raise ValueError(f"Minimum deposit is {MIN_DEPOSIT_USDC} USDC")12351236amount_base = int(amount * (10 ** USDC_DECIMALS))12371238# Step 1: ERC-20 approve(bridge, amount)1239approve_selector = keccak(b"approve(address,uint256)")[:4]1240bridge_bytes = bytes.fromhex(BRIDGE_ADDRESS.replace("0x", "")).rjust(32, b"\x00")1241amount_bytes = amount_base.to_bytes(32, "big")1242approve_data = "0x" + (approve_selector + bridge_bytes + amount_bytes).hex()12431244logger.info(f"HL deposit: approve TX (amount_base={amount_base})")1245approve_result = await _wallet_request("POST", "/agent/transfer", {1246"to": USDC_ADDRESS,1247"amount": "0",1248"data": approve_data,1249"chain_id": ARBITRUM_CHAIN_ID,1250})1251approve_tx = approve_result.get("tx_hash", approve_result.get("hash", "unknown"))1252logger.info(f"HL deposit: approve TX = {approve_tx}")12531254# Step 2: ERC-20 transfer(bridge, amount)1255transfer_selector = keccak(b"transfer(address,uint256)")[:4]1256transfer_data = "0x" + (transfer_selector + bridge_bytes + amount_bytes).hex()12571258logger.info(f"HL deposit: transfer TX to bridge {BRIDGE_ADDRESS}")1259transfer_result = await _wallet_request("POST", "/agent/transfer", {1260"to": USDC_ADDRESS,1261"amount": "0",1262"data": transfer_data,1263"chain_id": ARBITRUM_CHAIN_ID,1264})1265transfer_tx = transfer_result.get("tx_hash", transfer_result.get("hash", "unknown"))1266logger.info(f"HL deposit: transfer TX = {transfer_tx}")12671268return {1269"approve_tx_hash": approve_tx,1270"transfer_tx_hash": transfer_tx,1271"amount_deposited": amount,1272"amount_base_units": amount_base,1273"bridge_contract": BRIDGE_ADDRESS,1274"chain_id": ARBITRUM_CHAIN_ID,1275}12761277# ── Address helper ───────────────────────────────────────────────────12781279_cached_address: Optional[str] = None12801281async def _get_address(self) -> str:1282"""Get the agent's EVM address from wallet service (cached)."""1283if self._cached_address:1284return self._cached_address12851286from core.wallet_runtime import wallet_request as _wallet_request, is_fly_machine as _is_fly_machine1287if not _is_fly_machine():1288raise RuntimeError("Not running on Fly — wallet unavailable")12891290data = await _wallet_request("GET", "/agent/wallet")1291wallets = data if isinstance(data, list) else data.get("wallets", [])1292for w in wallets:1293if w.get("chain_type") == "ethereum":1294self._cached_address = w["wallet_address"]1295return self._cached_address12961297raise RuntimeError("No ethereum wallet found")1298