diff --git a/hyperliquid/client.go b/hyperliquid/client.go index 7233069..3ebef22 100644 --- a/hyperliquid/client.go +++ b/hyperliquid/client.go @@ -22,7 +22,10 @@ type IClient interface { IAPIService SetPrivateKey(privateKey string) error SetAccountAddress(address string) + SetVaultAddress(address string) + SetUserRole(role Role) AccountAddress() string + VaultAddress() string SetDebugActive() IsMainnet() bool } @@ -33,14 +36,16 @@ type IClient interface { // the network type, the private key, and the logger. // The debug method prints the debug messages. type Client struct { - baseUrl string // Base URL of the HyperLiquid API + baseURL string // Base URL of the HyperLiquid API privateKey string // Private key for the client - defualtAddress string // Default address for the client + defaultAddress string // Default address for the client isMainnet bool // Network type Debug bool // Debug mode httpClient *http.Client // HTTP client keyManager *PKeyManager // Private key manager Logger *log.Logger // Logger for debug messages + role Role // Role of the client, + vaultAddress string // Vault address } // Returns the private key manager connected to the API. @@ -60,19 +65,21 @@ func getURL(isMainnet bool) string { // NewClient returns a new instance of the Client struct. func NewClient(isMainnet bool) *Client { logger := log.New() + logger.SetLevel(log.DebugLevel) logger.SetFormatter(&log.TextFormatter{ FullTimestamp: true, PadLevelText: true, + ForceColors: true, }) logger.SetOutput(os.Stdout) logger.SetLevel(log.DebugLevel) return &Client{ - baseUrl: getURL(isMainnet), + baseURL: getURL(isMainnet), httpClient: http.DefaultClient, Debug: false, isMainnet: isMainnet, privateKey: "", - defualtAddress: "", + defaultAddress: "", Logger: logger, keyManager: nil, } @@ -100,12 +107,30 @@ func (client *Client) SetPrivateKey(privateKey string) error { // In case you use PKeyManager from API section https://app.hyperliquid.xyz/API // Then you can use this method to set the address. func (client *Client) SetAccountAddress(address string) { - client.defualtAddress = address + client.defaultAddress = address } // Returns the public address connected to the API. func (client *Client) AccountAddress() string { - return client.defualtAddress + return client.defaultAddress +} + +// VaultAddress returns the vault address for the client. +func (client *Client) VaultAddress() string { + return client.vaultAddress +} + +// SetVaultAddress sets the vault address for the client. +func (client *Client) SetVaultAddress(vaultAddress string) { + client.vaultAddress = vaultAddress +} + +// SetUserRole sets the user role for the client. +func (client *Client) SetUserRole(role Role) { + client.role = role + if role.IsVaultOrSubAccount() { + client.vaultAddress = client.AccountAddress() + } } // IsMainnet returns true if the client is connected to the mainnet. @@ -121,7 +146,7 @@ func (client *Client) SetDebugActive() { // Request sends a POST request to the HyperLiquid API. func (client *Client) Request(endpoint string, payload any) ([]byte, error) { endpoint = strings.TrimPrefix(endpoint, "/") // Remove leading slash if present - url := fmt.Sprintf("%s/%s", client.baseUrl, endpoint) + url := fmt.Sprintf("%s/%s", client.baseURL, endpoint) client.debug("Request to %s", url) jsonPayload, err := json.Marshal(payload) if err != nil { diff --git a/hyperliquid/convert.go b/hyperliquid/convert.go index be3d340..7fb95b4 100644 --- a/hyperliquid/convert.go +++ b/hyperliquid/convert.go @@ -34,6 +34,21 @@ func HexToBytes(addr string) []byte { return b } } +func HexToInt(hexString string) (*big.Int, error) { + value := new(big.Int) + if len(hexString) > 1 && hexString[:2] == "0x" { + hexString = hexString[2:] + } + _, success := value.SetString(hexString, 16) + if !success { + return nil, fmt.Errorf("invalid hexadecimal string: %s", hexString) + } + return value, nil +} + +func IntToHex(value *big.Int) string { + return "0x" + value.Text(16) +} func OrderWiresToOrderAction(orders []OrderWire, grouping Grouping) PlaceOrderAction { return PlaceOrderAction{ @@ -43,19 +58,35 @@ func OrderWiresToOrderAction(orders []OrderWire, grouping Grouping) PlaceOrderAc } } -func OrderRequestToWire(req OrderRequest, meta map[string]AssetInfo, isSpot bool) OrderWire { +func (req *OrderRequest) isSpot() bool { + return strings.ContainsAny(req.Coin, "@-") +} + +// ToWire (OrderRequest) converts an OrderRequest to an OrderWire using the provided metadata. +// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids +func (req *OrderRequest) ToWireMeta(meta map[string]AssetInfo) OrderWire { info := meta[req.Coin] - var assetId, maxDecimals int - if isSpot { - // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids - assetId = info.AssetId + 10000 + return req.ToWire(info) +} + +// ToModifyWire converts an OrderRequest to a ModifyOrderWire using the provided AssetInfo. +func (req *OrderRequest) ToModifyWire(info AssetInfo) ModifyOrderWire { + return ModifyOrderWire{ + OrderID: *req.OrderID, + Order: req.ToWire(info), + } +} + +// ToWire converts an OrderRequest to an OrderWire using the provided AssetInfo. +func (req *OrderRequest) ToWire(info AssetInfo) OrderWire { + var assetID = info.AssetID + var maxDecimals = PERP_MAX_DECIMALS + if req.isSpot() { + assetID = info.AssetID + 10000 // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids maxDecimals = SPOT_MAX_DECIMALS - } else { - assetId = info.AssetId - maxDecimals = PERP_MAX_DECIMALS } return OrderWire{ - Asset: assetId, + Asset: assetID, IsBuy: req.IsBuy, LimitPx: PriceToWire(req.LimitPx, maxDecimals, info.SzDecimals), SizePx: SizeToWire(req.Sz, info.SzDecimals), @@ -65,30 +96,7 @@ func OrderRequestToWire(req OrderRequest, meta map[string]AssetInfo, isSpot bool } } -func ModifyOrderRequestToWire(req ModifyOrderRequest, meta map[string]AssetInfo, isSpot bool) ModifyOrderWire { - info := meta[req.Coin] - var assetId, maxDecimals int - if isSpot { - // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids - assetId = info.AssetId + 10000 - maxDecimals = SPOT_MAX_DECIMALS - } else { - assetId = info.AssetId - maxDecimals = PERP_MAX_DECIMALS - } - return ModifyOrderWire{ - OrderId: req.OrderId, - Order: OrderWire{ - Asset: assetId, - IsBuy: req.IsBuy, - LimitPx: PriceToWire(req.LimitPx, maxDecimals, info.SzDecimals), - SizePx: SizeToWire(req.Sz, info.SzDecimals), - ReduceOnly: req.ReduceOnly, - OrderType: OrderTypeToWire(req.OrderType), - }, - } -} - +// OrderTypeToWire converts an OrderType to an OrderTypeWire. func OrderTypeToWire(orderType OrderType) OrderTypeWire { if orderType.Limit != nil { return OrderTypeWire{ @@ -110,8 +118,26 @@ func OrderTypeToWire(orderType OrderType) OrderTypeWire { return OrderTypeWire{} } -// Format the float with custom decimal places, default is 6 (perp), 8 (spot). -// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size +/** + * FloatToWire converts a float64 to a string representation following Hyperliquid's decimal rules. + * FloatToWire converts a float64 to a string representation following Hyperliquid's decimal rules. + * + * The conversion adheres to market-specific decimal place constraints: + * - Perpetual markets: Maximum 6 decimal places + * - Spot markets: Maximum 8 decimal places + * + * The function dynamically adjusts decimal precision based on: + * 1. Integer part magnitude + * 2. Maximum allowed decimals (maxDecimals) + * 3. Size decimal precision (szDecimals) + * + * Output formatting: + * - Removes trailing zeros + * - Trims unnecessary decimal points + * - Maintains tick size precision requirements + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size + */ func FloatToWire(x float64, maxDecimals int, szDecimals int) string { bigf := big.NewFloat(x) var maxDecSz uint @@ -228,3 +254,38 @@ func StructToMap(strct any) (res map[string]interface{}, err error) { json.Unmarshal(a, &res) return res, nil } + +// RoundOrderSize rounds the order size to the nearest tick size +func RoundOrderSize(x float64, szDecimals int) string { + newX := math.Round(x*math.Pow10(szDecimals)) / math.Pow10(szDecimals) + // TODO: add rounding + return big.NewFloat(newX).Text('f', szDecimals) +} + +// RoundOrderPrice rounds the order price to the nearest tick size +func RoundOrderPrice(x float64, szDecimals int, maxDecimals int) string { + maxSignFigures := 5 + allowedDecimals := maxDecimals - szDecimals + numberOfDigitsInIntegerPart := len(strconv.Itoa(int(x))) + if numberOfDigitsInIntegerPart >= maxSignFigures { + return RoundOrderSize(x, 0) + } + allowedSignFigures := maxSignFigures - numberOfDigitsInIntegerPart + if x >= 1 { + return RoundOrderSize(x, min(allowedSignFigures, allowedDecimals)) + } + + text := RoundOrderSize(x, allowedDecimals) + startSignFigures := false + for i := 2; i < len(text); i++ { + if text[i] == '0' && !startSignFigures { + continue + } + startSignFigures = true + allowedSignFigures-- + if allowedSignFigures == 0 { + return text[:i+1] + } + } + return text +} diff --git a/hyperliquid/exchange_service.go b/hyperliquid/exchange_service.go index 42587a1..7fc61e7 100644 --- a/hyperliquid/exchange_service.go +++ b/hyperliquid/exchange_service.go @@ -3,6 +3,8 @@ package hyperliquid import ( "fmt" "math" + "strconv" + "strings" "github.com/ethereum/go-ethereum/signer/core/apitypes" ) @@ -14,13 +16,15 @@ type IExchangeAPI interface { // Open orders BulkOrders(requests []OrderRequest, grouping Grouping) (*OrderResponse, error) Order(request OrderRequest, grouping Grouping) (*OrderResponse, error) - MarketOrder(coin string, size float64, slippage *float64, clientOID ...string) (*OrderResponse, error) - LimitOrder(orderType string, coin string, size float64, px float64, isBuy bool, reduceOnly bool, clientOID ...string) (*OrderResponse, error) + MarketOrder(coin string, size float64, slippage *float64) (*OrderResponse, error) + LimitOrder(orderType string, coin string, size float64, px float64, isBuy bool, reduceOnly bool) (*OrderResponse, error) // Order management CancelOrderByOID(coin string, orderID int) (any, error) - CancelOrderByCloid(coin string, clientOID string) (any, error) + CancelOrderByCloid(symbol string, clientOrderID string) (*OrderResponse, error) BulkCancelOrders(cancels []CancelOidWire) (any, error) + BulkCancelOrdersByCloid(entries []CancelCloidWire) (*OrderResponse, error) + CancelAllOrdersByCoin(coin string) (any, error) CancelAllOrders() (any, error) ClosePosition(coin string) (*OrderResponse, error) @@ -38,6 +42,7 @@ type ExchangeAPI struct { baseEndpoint string meta map[string]AssetInfo spotMeta map[string]AssetInfo + role string } // NewExchangeAPI creates a new default ExchangeAPI. @@ -63,18 +68,9 @@ func NewExchangeAPI(isMainnet bool) *ExchangeAPI { api.debug("Error building spot meta map: %s", err) } api.spotMeta = spotMeta - return &api } -// -// Helpers -// - -func (api *ExchangeAPI) Endpoint() string { - return api.baseEndpoint -} - // Helper function to calculate the slippage price based on the market price. func (api *ExchangeAPI) SlippagePrice(coin string, isBuy bool, slippage float64) float64 { marketPx, err := api.infoAPI.GetMartketPx(coin) @@ -108,11 +104,12 @@ func (api *ExchangeAPI) getChainParams() (string, string) { func (api *ExchangeAPI) BuildBulkOrdersEIP712(requests []OrderRequest, grouping Grouping) (apitypes.TypedData, error) { var wires []OrderWire for _, req := range requests { - wires = append(wires, OrderRequestToWire(req, api.meta, false)) + meta := api.GetMeta(req) + wires = append(wires, req.ToWire(meta)) } timestamp := GetNonce() action := OrderWiresToOrderAction(wires, grouping) - srequest, err := api.BuildEIP712Message(action, timestamp) + srequest, err := api.BuildEIP712Message(action, timestamp, api.VaultAddress()) if err != nil { api.debug("Error building EIP712 message: %s", err) return apitypes.TypedData{}, err @@ -131,16 +128,12 @@ func (api *ExchangeAPI) BuildOrderEIP712(request OrderRequest, grouping Grouping // Place orders in bulk // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#place-an-order -func (api *ExchangeAPI) BulkOrders(requests []OrderRequest, grouping Grouping, isSpot bool) (*OrderResponse, error) { +func (api *ExchangeAPI) BulkOrders(requests []OrderRequest, grouping Grouping) (*OrderResponse, error) { var wires []OrderWire - var meta map[string]AssetInfo - if isSpot { - meta = api.spotMeta - } else { - meta = api.meta - } + var meta AssetInfo for _, req := range requests { - wires = append(wires, OrderRequestToWire(req, meta, isSpot)) + meta = api.GetMeta(req) + wires = append(wires, req.ToWire(meta)) } timestamp := GetNonce() action := OrderWiresToOrderAction(wires, grouping) @@ -182,11 +175,12 @@ func (api *ExchangeAPI) BulkCancelOrders(cancels []CancelOidWire) (*OrderRespons // Bulk modify orders // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint#modify-multiple-orders -func (api *ExchangeAPI) BulkModifyOrders(modifyRequests []ModifyOrderRequest, isSpot bool) (*OrderResponse, error) { +func (api *ExchangeAPI) BulkModifyOrders(modifyRequests []OrderRequest) (*OrderResponse, error) { wires := []ModifyOrderWire{} for _, req := range modifyRequests { - wires = append(wires, ModifyOrderRequestToWire(req, api.meta, isSpot)) + info := api.GetMeta(req) + wires = append(wires, req.ToModifyWire(info)) } action := ModifyOrderAction{ Type: "batchModify", @@ -215,7 +209,7 @@ func (api *ExchangeAPI) CancelOrderByCloid(coin string, clientOID string) (*Orde Type: "cancelByCloid", Cancels: []CancelCloidWire{ { - Asset: api.meta[coin].AssetId, + Asset: api.meta[coin].AssetID, Cloid: clientOID, }, }, @@ -240,7 +234,7 @@ func (api *ExchangeAPI) UpdateLeverage(coin string, isCross bool, leverage int) timestamp := GetNonce() action := UpdateLeverageAction{ Type: "updateLeverage", - Asset: api.meta[coin].AssetId, + Asset: api.meta[coin].AssetID, IsCross: isCross, Leverage: leverage, } @@ -291,10 +285,34 @@ func (api *ExchangeAPI) Withdraw(destination string, amount float64) (*WithdrawR // Place single order func (api *ExchangeAPI) Order(request OrderRequest, grouping Grouping) (*OrderResponse, error) { - return api.BulkOrders([]OrderRequest{request}, grouping, false) + return api.BulkOrders([]OrderRequest{request}, grouping) +} + +// Endpoint returns the base endpoint for the /exchange service. +func (api *ExchangeAPI) Endpoint() string { + return api.baseEndpoint } -// Open a market order. +// GetSpotMarketPx returns the market price of a given spot coin +// The coin parameter is the name of the coin +// +// Example: +// +// api.GetSpotMarketPx("HYPE") +func (api *InfoAPI) GetSpotMarketPx(coin string) (float64, error) { + spotPrices, err := api.GetAllSpotPrices() + if err != nil { + return 0, err + } + spotName := api.spotMeta[coin].SpotName + parsed, err := strconv.ParseFloat((*spotPrices)[spotName], 32) + if err != nil { + return 0, err + } + return parsed, nil +} + +// MarketOrder places a market order. // Limit order with TIF=IOC and px=market price * (1 +- slippage). // Size determines the amount of the coin to buy/sell. // @@ -352,11 +370,10 @@ func (api *ExchangeAPI) MarketOrderSpot(coin string, size float64, slippage *flo return api.OrderSpot(orderRequest, GroupingNa) } -// Open a limit order. -// Order type can be Gtc, Ioc, Alo. +// LimitOrder places a limit order // Size determines the amount of the coin to buy/sell. // See the constants TifGtc, TifIoc, TifAlo. -func (api *ExchangeAPI) LimitOrder(orderType string, coin string, size float64, px float64, reduceOnly bool, clientOID ...string) (*OrderResponse, error) { +func (api *ExchangeAPI) LimitOrder(orderType string, coin string, size float64, px float64, reduceOnly bool, cloid ...string) (*OrderResponse, error) { // check if the order type is valid if orderType != TifGtc && orderType != TifIoc && orderType != TifAlo { return nil, APIError{Message: fmt.Sprintf("Invalid order type: %s. Available types: %s, %s, %s", orderType, TifGtc, TifIoc, TifAlo)} @@ -374,8 +391,11 @@ func (api *ExchangeAPI) LimitOrder(orderType string, coin string, size float64, OrderType: orderTypeZ, ReduceOnly: reduceOnly, } - if len(clientOID) > 0 { - orderRequest.Cloid = clientOID[0] + if len(cloid) > 0 { + if _, err := HexToInt(cloid[0]); err != nil { + return nil, err + } + orderRequest.Cloid = cloid[0] } return api.Order(orderRequest, GroupingNa) } @@ -422,12 +442,42 @@ func (api *ExchangeAPI) ClosePosition(coin string) (*OrderResponse, error) { // OrderSpot places a spot order func (api *ExchangeAPI) OrderSpot(request OrderRequest, grouping Grouping) (*OrderResponse, error) { - return api.BulkOrders([]OrderRequest{request}, grouping, true) + return api.BulkOrders([]OrderRequest{request}, grouping) } // Cancel exact order by OID -func (api *ExchangeAPI) CancelOrderByOID(coin string, orderID int64) (*OrderResponse, error) { - return api.BulkCancelOrders([]CancelOidWire{{Asset: api.meta[coin].AssetId, Oid: int(orderID)}}) +func (api *ExchangeAPI) CancelOrderByOID(coin string, orderID int) (*OrderResponse, error) { + meta := api.meta + if strings.ContainsAny(coin, "@-") { + meta = api.spotMeta + } + assetID := meta[coin].AssetID + return api.BulkCancelOrders([]CancelOidWire{{Asset: assetID, Oid: orderID}}) +} + +func (api *ExchangeAPI) BulkCancelOrdersByCloid(cancels []CancelCloidWire) (*OrderResponse, error) { + if len(cancels) == 0 { + return nil, APIError{Message: "no cloID entries provided"} + } + nonceValue := GetNonce() + + action := CancelCloidOrderAction{ + Type: "cancelByCloid", + Cancels: cancels, + } + v, r, s, err := api.SignL1Action(action, nonceValue) + if err != nil { + api.debug("BulkCancelOrdersByCloid sign error: %s", err) + return nil, err + } + vaultAddress := api.VaultAddress() + request := ExchangeRequest{ + Action: action, + Nonce: nonceValue, + Signature: ToTypedSig(r, s, v), + VaultAddress: &vaultAddress, + } + return MakeUniversalRequest[OrderResponse](api, request) } // Cancel all orders for a given coin @@ -442,7 +492,7 @@ func (api *ExchangeAPI) CancelAllOrdersByCoin(coin string) (*OrderResponse, erro if coin != order.Coin { continue } - cancels = append(cancels, CancelOidWire{Asset: api.meta[coin].AssetId, Oid: int(order.Oid)}) + cancels = append(cancels, CancelOidWire{Asset: api.meta[coin].AssetID, Oid: int(order.Oid)}) } return api.BulkCancelOrders(cancels) } @@ -459,7 +509,32 @@ func (api *ExchangeAPI) CancelAllOrders() (*OrderResponse, error) { } var cancels []CancelOidWire for _, order := range *orders { - cancels = append(cancels, CancelOidWire{Asset: api.meta[order.Coin].AssetId, Oid: int(order.Oid)}) + cancels = append(cancels, CancelOidWire{Asset: api.meta[order.Coin].AssetID, Oid: int(order.Oid)}) } return api.BulkCancelOrders(cancels) } + +// GetMeta returns the asset info for the given request. +// If the request is a spot request, it returns the spot meta map. +// Returns empty AssetInfo if coin not found in meta map. +func (api *ExchangeAPI) GetMeta(req OrderRequest) AssetInfo { + if req.Coin == "" { + return AssetInfo{} + } + + meta := api.meta + if req.isSpot() { + if api.spotMeta == nil { + return AssetInfo{} + } + meta = api.spotMeta + } else if api.meta == nil { + return AssetInfo{} + } + + assetInfo, exists := meta[req.Coin] + if !exists { + return AssetInfo{} + } + return assetInfo +} diff --git a/hyperliquid/exchange_signing.go b/hyperliquid/exchange_signing.go index 42cdb27..1b5b2aa 100644 --- a/hyperliquid/exchange_signing.go +++ b/hyperliquid/exchange_signing.go @@ -34,7 +34,7 @@ func (api *ExchangeAPI) SignUserSignableAction(action any, payloadTypes []apityp } func (api *ExchangeAPI) SignL1Action(action any, timestamp uint64) (byte, [32]byte, [32]byte, error) { - srequest, err := api.BuildEIP712Message(action, timestamp) + srequest, err := api.BuildEIP712Message(action, timestamp, api.VaultAddress()) if err != nil { api.debug("Error building EIP712 message: %s", err) return 0, [32]byte{}, [32]byte{}, err @@ -42,8 +42,8 @@ func (api *ExchangeAPI) SignL1Action(action any, timestamp uint64) (byte, [32]by return api.Sign(srequest) } -func (api *ExchangeAPI) BuildEIP712Message(action any, timestamp uint64) (*SignRequest, error) { - hash, err := buildActionHash(action, "", timestamp) +func (api *ExchangeAPI) BuildEIP712Message(action any, timestamp uint64, vaultAddress string) (*SignRequest, error) { + hash, err := buildActionHash(action, vaultAddress, timestamp) if err != nil { return nil, err } diff --git a/hyperliquid/exchange_test.go b/hyperliquid/exchange_test.go index 49abdab..7d40a51 100644 --- a/hyperliquid/exchange_test.go +++ b/hyperliquid/exchange_test.go @@ -214,7 +214,7 @@ func TestExchangeAPI_CreateLimitOrderAndCancelOrderByOid(t *testing.T) { t.Errorf("Order not found: %v", openOrders) } time.Sleep(5 * time.Second) // wait to execute order - cancelRes, err := exchangeAPI.CancelOrderByOID(coin, orderOid) + cancelRes, err := exchangeAPI.CancelOrderByOID(coin, int(orderOid)) if err != nil { t.Errorf("CancelOrderByOid() error = %v", err) } @@ -243,7 +243,7 @@ func TestExchangeAPI_TestModifyOrder(t *testing.T) { break } } - log.Printf("Order ID: %v", res.Response.Data.Statuses[0].Resting.OrderId) + log.Printf("Order ID: %v", res.Response.Data.Statuses[0].Resting.OrderID) if !orderOpened { t.Errorf("Order not found: %+v", openOrders) } @@ -255,8 +255,10 @@ func TestExchangeAPI_TestModifyOrder(t *testing.T) { Tif: TifGtc, }, } - modifyOrderRequest := ModifyOrderRequest{ - OrderId: res.Response.Data.Statuses[0].Resting.OrderId, + orderID := res.Response.Data.Statuses[0].Resting.OrderID + + modifyOrderRequest := OrderRequest{ + OrderID: &orderID, Coin: coin, Sz: size, LimitPx: newPx, @@ -264,7 +266,7 @@ func TestExchangeAPI_TestModifyOrder(t *testing.T) { IsBuy: true, ReduceOnly: false, } - modifyRes, err := exchangeAPI.BulkModifyOrders([]ModifyOrderRequest{modifyOrderRequest}, false) + modifyRes, err := exchangeAPI.BulkModifyOrders([]OrderRequest{modifyOrderRequest}) if err != nil { t.Errorf("ModifyOrder() error = %v", err) } diff --git a/hyperliquid/exchange_types.go b/hyperliquid/exchange_types.go index e2e621a..54449f9 100644 --- a/hyperliquid/exchange_types.go +++ b/hyperliquid/exchange_types.go @@ -11,7 +11,6 @@ type RsvSignature struct { V byte `json:"v"` } -// Base request for /exchange endpoint type ExchangeRequest struct { Action any `json:"action"` Nonce uint64 `json:"nonce"` @@ -22,11 +21,12 @@ type ExchangeRequest struct { type AssetInfo struct { SzDecimals int WeiDecimals int - AssetId int + AssetID int SpotName string // for spot asset (e.g. "@107") } type OrderRequest struct { + OrderID *int `json:"order_id"` Coin string `json:"coin"` IsBuy bool `json:"is_buy"` Sz float64 `json:"sz"` @@ -38,7 +38,7 @@ type OrderRequest struct { type OrderType struct { Limit *LimitOrderType `json:"limit,omitempty" msgpack:"limit,omitempty"` - Trigger *TriggerOrderType `json:"trigger,omitempty" msgpack:"trigger,omitempty"` + Trigger *TriggerOrderType `json:"trigger,omitempty" msgpack:"trigger,omitempty"` } type LimitOrderType struct { @@ -85,26 +85,17 @@ type ModifyResponse struct { Status string `json:"status"` Response OrderInnerResponse `json:"response"` } + type ModifyOrderWire struct { - OrderId int `msgpack:"oid" json:"oid"` + OrderID int `msgpack:"oid" json:"oid"` Order OrderWire `msgpack:"order" json:"order"` } + type ModifyOrderAction struct { Type string `msgpack:"type" json:"type"` Modifies []ModifyOrderWire `msgpack:"modifies" json:"modifies"` } -type ModifyOrderRequest struct { - OrderId int `json:"oid"` - Coin string `json:"coin"` - IsBuy bool `json:"is_buy"` - Sz float64 `json:"sz"` - LimitPx float64 `json:"limit_px"` - OrderType OrderType `json:"order_type"` - ReduceOnly bool `json:"reduce_only"` - Cloid string `json:"cloid,omitempty"` -} - type OrderTypeWire struct { Limit *LimitOrderType `json:"limit,omitempty" msgpack:"limit,omitempty"` Trigger *TriggerOrderType `json:"trigger,omitempty" msgpack:"trigger,omitempty"` @@ -160,7 +151,7 @@ func (sr *StatusResponse) UnmarshalJSON(data []byte) error { } type CancelRequest struct { - OrderId int `json:"oid"` + OrderID int `json:"oid"` Coin int `json:"coin"` } @@ -169,6 +160,10 @@ type CancelOidOrderAction struct { Cancels []CancelOidWire `msgpack:"cancels" json:"cancels"` } +type CancelCloidOrderAction struct { + Type string `msgpack:"type" json:"type"` + Cancels []CancelCloidWire `msgpack:"cancels" json:"cancels"` +} type CancelOidWire struct { Asset int `msgpack:"a" json:"a"` Oid int `msgpack:"o" json:"o"` @@ -176,17 +171,25 @@ type CancelOidWire struct { type CancelCloidWire struct { Asset int `msgpack:"asset" json:"asset"` - Cloid string `msgpack:"cloid" json:"cloid"` + Cloid string `json:"cloid"` +} +type CancelOrderResponse struct { + Status string `json:"status"` + Response InnerCancelResponse `json:"response"` } -type CancelCloidOrderAction struct { - Type string `msgpack:"type" json:"type"` - Cancels []CancelCloidWire `msgpack:"cancels" json:"cancels"` +type InnerCancelResponse struct { + Type string `json:"type"` + Data CancelResponseStatuses `json:"data"` +} + +type CancelResponseStatuses struct { + Statuses []StatusResponse `json:"statuses"` } type RestingStatus struct { - OrderId int `json:"oid"` - Cloid string `json:"cloid,omitempty"` + OrderID int `json:"oid"` + Cloid string `json:"cloid"` } type CloseRequest struct { @@ -198,7 +201,7 @@ type CloseRequest struct { } type FilledStatus struct { - OrderId int `json:"oid"` + OrderID int `json:"oid"` AvgPx float64 `json:"avgPx,string"` TotalSz float64 `json:"totalSz,string"` Cloid string `json:"cloid,omitempty"` @@ -212,9 +215,9 @@ type Liquidation struct { type UpdateLeverageAction struct { Type string `msgpack:"type" json:"type"` - Asset int `msgpack:"asset" json:"asset"` - IsCross bool `msgpack:"isCross" json:"isCross"` - Leverage int `msgpack:"leverage" json:"leverage"` + Asset int `json:"asset"` + IsCross bool `json:"isCross"` + Leverage int `json:"leverage"` } type DefaultExchangeResponse struct { @@ -224,7 +227,6 @@ type DefaultExchangeResponse struct { } `json:"response"` } -// Depending on Type this struct can has different non-nil fields type NonFundingDelta struct { Type string `json:"type"` Usdc float64 `json:"usdc,string,omitempty"` @@ -257,15 +259,15 @@ type Deposit struct { } type WithdrawAction struct { - Type string `msgpack:"type" json:"type"` - Destination string `msgpack:"destination" json:"destination"` - Amount string `msgpack:"amount" json:"amount"` - Time uint64 `msgpack:"time" json:"time"` - HyperliquidChain string `msgpack:"hyperliquidChain" json:"hyperliquidChain"` - SignatureChainID string `msgpack:"signatureChainId" json:"signatureChainId"` + Type string `json:"type" msgpack:"type"` + Destination string `json:"destination" msgpack:"destination"` + Amount string `json:"amount" msgpack:"amount"` + Time uint64 `json:"time" msgpack:"time"` + HyperliquidChain string `json:"hyperliquidChain" msgpack:"hyperliquidChain"` + SignatureChainID string `json:"signatureChainId" msgpack:"signatureChainId"` } type WithdrawResponse struct { Status string `json:"status"` - Nonce int64 + Nonce int64 `json:"nonce"` } diff --git a/hyperliquid/hyperliquid.go b/hyperliquid/hyperliquid.go index 621515d..b6b7a97 100644 --- a/hyperliquid/hyperliquid.go +++ b/hyperliquid/hyperliquid.go @@ -10,16 +10,20 @@ type Hyperliquid struct { InfoAPI } -// HyperliquidClientConfig is a configuration struct for Hyperliquid API. -// PrivateKey can be empty if you only need to use the public endpoints. -// AccountAddress is the default account address for the API that can be changed with SetAccountAddress(). -// AccountAddress may be different from the address build from the private key due to Hyperliquid's account system. +// HyperliquidClientConfig represents the configuration options for the Hyperliquid client. +// It allows configuring the network type, private key, and account address settings. +// +// The configuration options include: +// - IsMainnet: Set to true for mainnet and false for testnet +// - PrivateKey: Optional key for authenticated endpoints (can be empty for public endpoints) +// - AccountAddress: Default account address used by the API (modifiable via SetAccountAddress) type HyperliquidClientConfig struct { IsMainnet bool PrivateKey string AccountAddress string } +// NewHyperliquid creates a new Hyperliquid API client. func NewHyperliquid(config *HyperliquidClientConfig) *Hyperliquid { var defaultConfig *HyperliquidClientConfig if config == nil { @@ -36,10 +40,12 @@ func NewHyperliquid(config *HyperliquidClientConfig) *Hyperliquid { exchangeAPI.SetAccountAddress(defaultConfig.AccountAddress) infoAPI := NewInfoAPI(defaultConfig.IsMainnet) infoAPI.SetAccountAddress(defaultConfig.AccountAddress) - return &Hyperliquid{ + hl := &Hyperliquid{ ExchangeAPI: *exchangeAPI, InfoAPI: *infoAPI, } + hl.UpdateAccountVaultAddress() + return hl } func (h *Hyperliquid) SetDebugActive() { @@ -60,6 +66,18 @@ func (h *Hyperliquid) SetAccountAddress(accountAddress string) { h.InfoAPI.SetAccountAddress(accountAddress) } +func (h *Hyperliquid) UpdateAccountVaultAddress() { + h.UpdateVaultAddress(h.AccountAddress()) +} + +func (h *Hyperliquid) UpdateVaultAddress(address string) error { + role, err := h.InfoAPI.GetUserRole(address) + if err != nil { + return err + } + h.ExchangeAPI.SetUserRole(role.UserRole.Role) + return nil +} func (h *Hyperliquid) AccountAddress() string { return h.ExchangeAPI.AccountAddress() } diff --git a/hyperliquid/hyperliquid_test.go b/hyperliquid/hyperliquid_test.go index 09372eb..c65f044 100644 --- a/hyperliquid/hyperliquid_test.go +++ b/hyperliquid/hyperliquid_test.go @@ -13,7 +13,8 @@ func GetHyperliquidAPI() *Hyperliquid { PrivateKey: os.Getenv("TEST_PRIVATE_KEY"), }) if GLOBAL_DEBUG { - hl.SetDebugActive() + hl.infoAPI.SetDebugActive() + hl.ExchangeAPI.SetDebugActive() } return hl } @@ -32,10 +33,9 @@ func TestHyperliquid_CheckFieldsConsistency(t *testing.T) { } else { apiUrl = TESTNET_API_URL } - if hl.InfoAPI.baseUrl != apiUrl { - t.Errorf("baseUrl = %v, want %v", hl.InfoAPI.baseUrl, apiUrl) + if hl.InfoAPI.baseURL != apiUrl { + t.Errorf("baseUrl = %v, want %v", hl.InfoAPI.baseURL, apiUrl) } - hl.SetDebugActive() if hl.InfoAPI.Debug != hl.ExchangeAPI.Debug { t.Errorf("debug = %v, want %v", hl.InfoAPI.Debug, hl.ExchangeAPI.Debug) } diff --git a/hyperliquid/info_service.go b/hyperliquid/info_service.go index 953e03f..ff2ab57 100644 --- a/hyperliquid/info_service.go +++ b/hyperliquid/info_service.go @@ -3,6 +3,7 @@ package hyperliquid import ( "encoding/json" "fmt" + "log" "strconv" ) @@ -35,6 +36,7 @@ type IInfoAPI interface { BuildMetaMap() (map[string]AssetInfo, error) GetWithdrawals(address string) (*[]Withdrawal, error) GetAccountWithdrawals() (*[]Withdrawal, error) + GetUserRole() (*UserRole, error) } type InfoAPI struct { @@ -60,6 +62,7 @@ func NewInfoAPI(isMainnet bool) *InfoAPI { return &api } +// Endpoint returns the base endpoint for the InfoAPI. func (api *InfoAPI) Endpoint() string { return api.baseEndpoint } @@ -68,7 +71,7 @@ func (api *InfoAPI) Endpoint() string { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#retrieve-mids-for-all-actively-traded-coins func (api *InfoAPI) GetAllMids() (*map[string]string, error) { request := InfoRequest{ - Typez: "allMids", + Type: "allMids", } return MakeUniversalRequest[map[string]string](api, request) } @@ -77,7 +80,7 @@ func (api *InfoAPI) GetAllMids() (*map[string]string, error) { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/spot#retrieve-spot-asset-contexts func (api *InfoAPI) GetAllSpotPrices() (*map[string]string, error) { request := InfoRequest{ - Typez: "spotMetaAndAssetCtxs", + Type: "spotMetaAndAssetCtxs", } response, err := MakeUniversalRequest[SpotMetaAndAssetCtxsResponse](api, request) if err != nil { @@ -112,8 +115,8 @@ func (api *InfoAPI) GetAllSpotPrices() (*map[string]string, error) { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#retrieve-a-users-open-orders func (api *InfoAPI) GetOpenOrders(address string) (*[]Order, error) { request := InfoRequest{ - User: address, - Typez: "openOrders", + User: address, + Type: "openOrders", } return MakeUniversalRequest[[]Order](api, request) } @@ -129,8 +132,8 @@ func (api *InfoAPI) GetAccountOpenOrders() (*[]Order, error) { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#retrieve-a-users-fills func (api *InfoAPI) GetUserFills(address string) (*[]OrderFill, error) { request := InfoRequest{ - User: address, - Typez: "userFills", + User: address, + Type: "userFills", } return MakeUniversalRequest[[]OrderFill](api, request) } @@ -146,8 +149,8 @@ func (api *InfoAPI) GetAccountFills() (*[]OrderFill, error) { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#query-user-rate-limits func (api *InfoAPI) GetUserRateLimits(address string) (*RatesLimits, error) { request := InfoRequest{ - User: address, - Typez: "userRateLimit", + User: address, + Type: "userRateLimit", } return MakeUniversalRequest[RatesLimits](api, request) } @@ -163,8 +166,8 @@ func (api *InfoAPI) GetAccountRateLimits() (*RatesLimits, error) { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#l2-book-snapshot func (api *InfoAPI) GetL2BookSnapshot(coin string) (*L2BookSnapshot, error) { request := InfoRequest{ - Typez: "l2Book", - Coin: coin, + Type: "l2Book", + Coin: coin, } return MakeUniversalRequest[L2BookSnapshot](api, request) } @@ -173,7 +176,7 @@ func (api *InfoAPI) GetL2BookSnapshot(coin string) (*L2BookSnapshot, error) { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candle-snapshot func (api *InfoAPI) GetCandleSnapshot(coin string, interval string, startTime int64, endTime int64) (*[]CandleSnapshot, error) { request := CandleSnapshotRequest{ - Typez: "candleSnapshot", + Type: "candleSnapshot", Req: CandleSnapshotSubRequest{ Coin: coin, Interval: interval, @@ -188,7 +191,7 @@ func (api *InfoAPI) GetCandleSnapshot(coin string, interval string, startTime in // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-perpetuals-metadata func (api *InfoAPI) GetMeta() (*Meta, error) { request := InfoRequest{ - Typez: "meta", + Type: "meta", } return MakeUniversalRequest[Meta](api, request) } @@ -196,7 +199,7 @@ func (api *InfoAPI) GetMeta() (*Meta, error) { // Retrieve spot metadata func (api *InfoAPI) GetSpotMeta() (*SpotMeta, error) { request := InfoRequest{ - Typez: "spotMeta", + Type: "spotMeta", } return MakeUniversalRequest[SpotMeta](api, request) } @@ -205,25 +208,25 @@ func (api *InfoAPI) GetSpotMeta() (*SpotMeta, error) { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-users-perpetuals-account-summary func (api *InfoAPI) GetUserState(address string) (*UserState, error) { request := UserStateRequest{ - User: address, - Typez: "clearinghouseState", + User: address, + Type: "clearinghouseState", } return MakeUniversalRequest[UserState](api, request) } -// Retrieve account's perpetuals account summary +// GetAccountState retrieve account's perpetuals account summary // The same as GetUserState but user is set to the account address // Check AccountAddress() or SetAccountAddress() if there is a need to set the account address func (api *InfoAPI) GetAccountState() (*UserState, error) { return api.GetUserState(api.AccountAddress()) } -// Retrieve user's spot account summary +// GetUserStateSpot retrieve's a user's spot account summary // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/spot#retrieve-a-users-token-balances func (api *InfoAPI) GetUserStateSpot(address string) (*UserStateSpot, error) { request := UserStateRequest{ - User: address, - Typez: "spotClearinghouseState", + User: address, + Type: "spotClearinghouseState", } return MakeUniversalRequest[UserStateSpot](api, request) } @@ -235,21 +238,23 @@ func (api *InfoAPI) GetAccountStateSpot() (*UserStateSpot, error) { return api.GetUserStateSpot(api.AccountAddress()) } -// Retrieve a user's funding history -// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-a-users-funding-history-or-non-funding-ledger-updates +// GetFundingUpdates retrieves user's funding history between startTime and endTime (Unix timestamps). +// Returns chronological funding payments for perpetual positions. See API docs for details. +// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-funding-updates func (api *InfoAPI) GetFundingUpdates(address string, startTime int64, endTime int64) (*[]FundingUpdate, error) { request := InfoRequest{ User: address, - Typez: "userFunding", + Type: "userFunding", StartTime: startTime, EndTime: endTime, } return MakeUniversalRequest[[]FundingUpdate](api, request) } -// Retrieve account's funding history +// GetAccountFundingUpdates retrieve's an account's funding history // The same as GetFundingUpdates but user is set to the account address // Check AccountAddress() or SetAccountAddress() if there is a need to set the account address +// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-a-users-funding-history-or-non-funding-ledger-updates func (api *InfoAPI) GetAccountFundingUpdates(startTime int64, endTime int64) (*[]FundingUpdate, error) { return api.GetFundingUpdates(api.AccountAddress(), startTime, endTime) } @@ -259,7 +264,7 @@ func (api *InfoAPI) GetAccountFundingUpdates(startTime int64, endTime int64) (*[ func (api *InfoAPI) GetNonFundingUpdates(address string, startTime int64, endTime int64) (*[]NonFundingUpdate, error) { request := InfoRequest{ User: address, - Typez: "userNonFundingLedgerUpdates", + Type: "userNonFundingLedgerUpdates", StartTime: startTime, EndTime: endTime, } @@ -273,11 +278,21 @@ func (api *InfoAPI) GetAccountNonFundingUpdates(startTime int64, endTime int64) return api.GetNonFundingUpdates(api.AccountAddress(), startTime, endTime) } +// Retrieve a user's role ("missing", "user", "agent", "vault", or "subAccount") +// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#query-a-users-role +func (api *InfoAPI) GetUserRole(address string) (*UserRoleResponse, error) { + request := InfoRequest{ + User: address, + Type: "userRole", + } + return MakeUniversalRequest[UserRoleResponse](api, request) +} + // Retrieve historical funding rates // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals#retrieve-historical-funding-rates func (api *InfoAPI) GetHistoricalFundingRates(coin string, startTime int64, endTime int64) (*[]HistoricalFundingRate, error) { request := InfoRequest{ - Typez: "fundingHistory", + Type: "fundingHistory", Coin: coin, StartTime: startTime, EndTime: endTime, @@ -303,25 +318,6 @@ func (api *InfoAPI) GetMartketPx(coin string) (float64, error) { return parsed, nil } -// GetSpotMarketPx returns the market price of a given spot coin -// The coin parameter is the name of the coin -// -// Example: -// -// api.GetSpotMarketPx("HYPE") -func (api *InfoAPI) GetSpotMarketPx(coin string) (float64, error) { - spotPrices, err := api.GetAllSpotPrices() - if err != nil { - return 0, err - } - spotName := api.spotMeta[coin].SpotName - parsed, err := strconv.ParseFloat((*spotPrices)[spotName], 32) - if err != nil { - return 0, err - } - return parsed, nil -} - // Helper function to get the withdrawals of a given address // By default returns last 90 days func (api *InfoAPI) GetWithdrawals(address string) (*[]Withdrawal, error) { @@ -388,12 +384,18 @@ func (api *InfoAPI) BuildMetaMap() (map[string]AssetInfo, error) { metaMap := make(map[string]AssetInfo) result, err := api.GetMeta() if err != nil { - return nil, err + log.Fatalf("Failed to get meta: %v", err) } for index, asset := range result.Universe { + if asset.Name == "BTC" { + metaMap["BTC"] = AssetInfo{ + SzDecimals: asset.SzDecimals, + AssetID: index, + } + } metaMap[asset.Name] = AssetInfo{ SzDecimals: asset.SzDecimals, - AssetId: index, + AssetID: index, } } return metaMap, nil @@ -431,7 +433,7 @@ func (api *InfoAPI) BuildSpotMetaMap() (map[string]AssetInfo, error) { metaMap[token.name] = AssetInfo{ SzDecimals: token.szDecimals, WeiDecimals: token.weiDecimals, - AssetId: universe.Index, + AssetID: universe.Index, SpotName: universe.Name, } } diff --git a/hyperliquid/info_types.go b/hyperliquid/info_types.go index bce157b..92a519e 100644 --- a/hyperliquid/info_types.go +++ b/hyperliquid/info_types.go @@ -3,7 +3,7 @@ package hyperliquid // Base request for /info type InfoRequest struct { User string `json:"user,omitempty"` - Typez string `json:"type"` + Type string `json:"type"` Oid string `json:"oid,omitempty"` Coin string `json:"coin,omitempty"` StartTime int64 `json:"startTime,omitempty"` @@ -11,10 +11,35 @@ type InfoRequest struct { } type UserStateRequest struct { - User string `json:"user"` - Typez string `json:"type"` + User string `json:"user"` + Type string `json:"type"` } +type Role string + +const ( + RoleUser Role = "user" + RoleAgent Role = "agent" + RoleVault Role = "vault" + RoleSubAccount Role = "subAccount" + RoleMissing Role = "missing" +) + +// UserRole represents a user role. +// Role can be one of "user", "agent", "vault", "subAccount" or "missing" +type UserRole struct { + Role Role `json:"role"` + Data struct { + Master string `json:"master,omitempty"` + } `json:"data,omitempty"` +} + +// IsVaultOrSubAccount checks if the role is either Vault or SubAccount. +func (r Role) IsVaultOrSubAccount() bool { + return r == RoleVault || r == RoleSubAccount +} + +// Asset represents an asset. type Asset struct { Name string `json:"name"` SzDecimals int `json:"szDecimals"` @@ -31,6 +56,10 @@ type UserState struct { Time int64 `json:"time"` } +type UserRoleResponse struct { + UserRole UserRole `json:"userRole"` +} + type AssetPosition struct { Position Position `json:"position"` Type string `json:"type"` @@ -53,19 +82,14 @@ type Position struct { SinceChan float64 `json:"sinceChange,string"` } `json:"cumFunding"` } - type UserStateSpot struct { Balances []SpotAssetPosition `json:"balances"` } +// SpotAssetPosition represents an asset position. +// +// SpotAssetPosition{"coin": "USDC", "token": 0, "hold": "0.0", "total": "14.625485", "entryNtl": "0.0"} type SpotAssetPosition struct { - /* - "coin": "USDC", - "token": 0, - "hold": "0.0", - "total": "14.625485", - "entryNtl": "0.0" - */ Coin string `json:"coin"` Token int `json:"token"` Hold float64 `json:"hold,string"` @@ -183,8 +207,8 @@ type CandleSnapshotSubRequest struct { } type CandleSnapshotRequest struct { - Typez string `json:"type"` - Req CandleSnapshotSubRequest `json:"req"` + Type string `json:"type"` + Req CandleSnapshotSubRequest `json:"req"` } type CandleSnapshot struct {