package exchange import ( "context" "crypto/ecdsa" "encoding/hex" "encoding/json" "fmt" "math" "strconv" "strings" "sync" "time" "github.com/ethereum/go-ethereum/crypto" hl "github.com/sonirico/go-hyperliquid" ) type HyperLiquidTrade struct { exchange *hl.Exchange info *hl.Info privateKey *ecdsa.PrivateKey mainAddress string nonceMu sync.Mutex lastNonce int64 configured bool // szDecimals maps coin name -> decimal places for size formatting // Populated from HL Meta on initExchange() szDecimals map[string]int } func NewHyperLiquidTrade(privateKeyHex, mainAddress, apiAddress string) (*HyperLiquidTrade, error) { if privateKeyHex == "" { return &HyperLiquidTrade{}, nil } keyHex := strings.TrimPrefix(privateKeyHex, "0x") keyBytes, err := hex.DecodeString(keyHex) if err != nil { return nil, fmt.Errorf("decode private key: %w", err) } privKey, err := crypto.ToECDSA(keyBytes) if err != nil { return nil, fmt.Errorf("to ECDSA: %w", err) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() info := hl.NewInfo(ctx, hl.MainnetAPIURL, true, nil, nil, nil) return &HyperLiquidTrade{ privateKey: privKey, mainAddress: mainAddress, info: info, configured: true, }, nil } func (h *HyperLiquidTrade) initExchange() error { if h.exchange != nil { return nil } if !h.configured { return fmt.Errorf("HL not configured") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() meta, err := h.info.Meta(ctx) if err != nil { return fmt.Errorf("meta: %w", err) } spotMeta, err := h.info.SpotMeta(ctx) if err != nil { return fmt.Errorf("spot meta: %w", err) } h.exchange = hl.NewExchange(ctx, h.privateKey, hl.MainnetAPIURL, meta, "", h.mainAddress, spotMeta, nil) // Build szDecimals map from HL Meta for correct size formatting h.szDecimals = make(map[string]int, len(meta.Universe)) for _, asset := range meta.Universe { h.szDecimals[asset.Name] = asset.SzDecimals } return nil } // GetSize returns a formatted size string for HL orders using the correct szDecimals. func (h *HyperLiquidTrade) GetSize(coin string, amountUSD, price float64) string { sz := amountUSD / price decimals, ok := h.szDecimals[coin] if !ok { // Fallback: 4 decimal places return fmt.Sprintf("%.4f", math.Floor(sz*10000)/10000) } switch decimals { case 0: sz = math.Floor(sz) if sz < 1 { sz = 1 } return fmt.Sprintf("%.0f", sz) case 1: sz = math.Floor(sz*10) / 10 if sz < 0.1 { sz = 0.1 } return fmt.Sprintf("%.1f", sz) case 2: sz = math.Floor(sz*100) / 100 if sz < 0.01 { sz = 0.01 } return fmt.Sprintf("%.2f", sz) default: mult := math.Pow10(decimals) sz = math.Floor(sz*mult) / mult if sz < 1/mult { sz = 1 / mult } return fmt.Sprintf("%."+strconv.Itoa(decimals)+"f", sz) } } func (h *HyperLiquidTrade) IsConfigured() bool { return h.configured } // PlaceMarketOrder places a market order and returns the raw JSON response. func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) { if !h.configured { return "", fmt.Errorf("HL not configured") } if err := h.initExchange(); err != nil { return "", fmt.Errorf("init: %w", err) } isBuy := side == "buy" size, _ := strconv.ParseFloat(sz, 64) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() mids, err := h.info.AllMids(ctx) if err != nil { return "", fmt.Errorf("mids: %w", err) } priceStr, ok := mids[coin] if !ok { return "", fmt.Errorf("coin %s not found", coin) } midPx, _ := strconv.ParseFloat(priceStr, 64) limitPx := midPx * 2.0 if !isBuy { limitPx = midPx * 0.5 } result, err := h.exchange.MarketOpen(ctx, coin, isBuy, size, &limitPx, 0.05, nil, nil) if err != nil { return "", fmt.Errorf("market open: %w", err) } respJSON, _ := json.Marshal(result) return string(respJSON), nil } // EstimateFeeFromResponse calculates the fee using the response's filled size × price // × configured taker rate. This is NOT an actual fee from HL — HL does not return // fee amounts in the order response. The result is equivalent to estimating from // TradeAmountUSD, but more accurate for partial fills since it uses actual filled sz/px. func (h *HyperLiquidTrade) EstimateFeeFromResponse(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) { // HL MarketOpen returns a single OrderStatus object (NOT wrapped in statuses array): // {"resting":..., "filled":{"totalSz":"82.5","avgPx":"0.12153","oid":52463955193}, "error":...} var resp struct { Resting *json.RawMessage `json:"resting,omitempty"` Filled *struct { TotalSz string `json:"totalSz"` AvgPx string `json:"avgPx"` } `json:"filled,omitempty"` Error *string `json:"error,omitempty"` } if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil { return 0, fmt.Errorf("parse: %w", err) } if resp.Filled != nil { sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64) px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64) if sz > 0 && px > 0 { return sz * px * takerFeePct / 100, nil } } return 0, fmt.Errorf("no filled status in response") } func (h *HyperLiquidTrade) GetBalance() (float64, error) { if !h.configured { return 0, fmt.Errorf("HL not configured") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // HL testnet USDC is on spot, not perp. Use SpotUserState. state, err := h.info.SpotUserState(ctx, h.mainAddress) if err != nil { return 0, fmt.Errorf("spot user state: %w", err) } for _, b := range state.Balances { if b.Coin == "USDC" { total, _ := strconv.ParseFloat(b.Total, 64) hold, _ := strconv.ParseFloat(b.Hold, 64) return total - hold, nil } } return 0, fmt.Errorf("USDC balance not found in spot state") }