refactor: HL EVM signing via sonirico/go-hyperliquid SDK
This commit is contained in:
+138
-153
@@ -1,239 +1,224 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto/ed25519"
|
||||
"crypto/sha512"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
hl "github.com/sonirico/go-hyperliquid"
|
||||
)
|
||||
|
||||
// HLOrderAction represents a HyperLiquid order action.
|
||||
type HLOrderAction struct {
|
||||
Type string `json:"type"`
|
||||
Order HLOrder `json:"order"`
|
||||
Grouping string `json:"grouping"`
|
||||
BrokerCode int `json:"brokerCode"`
|
||||
}
|
||||
|
||||
type HLOrder struct {
|
||||
Coin string `json:"coin"`
|
||||
IsBuy bool `json:"isBuy"`
|
||||
Sz string `json:"sz"`
|
||||
LimitPx string `json:"limitPx"`
|
||||
OrderType string `json:"orderType"`
|
||||
ReduceOnly bool `json:"reduceOnly"`
|
||||
}
|
||||
|
||||
// HLSignedAction wraps the action with signature.
|
||||
type HLSignedAction struct {
|
||||
Action HLOrderAction `json:"action"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
Signature string `json:"signature"`
|
||||
Address string `json:"address"` // API wallet address (signer)
|
||||
VaultAddress string `json:"vaultAddress,omitempty"`
|
||||
}
|
||||
|
||||
type HLOrderResponse struct {
|
||||
Response *json.RawMessage `json:"response"`
|
||||
Data *json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// HyperLiquidTrade handles order placement on HyperLiquid.
|
||||
// HyperLiquidTrade handles order placement on HyperLiquid using the SDK.
|
||||
type HyperLiquidTrade struct {
|
||||
PrivateKey ed25519.PrivateKey
|
||||
Address string
|
||||
APIAddress string // API wallet address (signer), derived from private key
|
||||
client *http.Client
|
||||
lastNonce int64
|
||||
nonceMu sync.Mutex // protect lastNonce++ (Issue #4)
|
||||
exchange *hl.Exchange
|
||||
info *hl.Info
|
||||
privateKey *ecdsa.PrivateKey
|
||||
mainAddress string // main account address
|
||||
nonceMu sync.Mutex
|
||||
lastNonce int64
|
||||
configured bool
|
||||
}
|
||||
|
||||
func NewHyperLiquidTrade(privateKeyHex, mainAddress, apiAddress string) (*HyperLiquidTrade, error) {
|
||||
if privateKeyHex == "" {
|
||||
return &HyperLiquidTrade{client: &http.Client{Timeout: 10 * time.Second}}, nil
|
||||
return &HyperLiquidTrade{}, nil
|
||||
}
|
||||
|
||||
// Try hex decode first
|
||||
keyBytes, err := hex.DecodeString(privateKeyHex)
|
||||
// Parse ECDSA private key (supports 0x prefix)
|
||||
keyHex := strings.TrimPrefix(privateKeyHex, "0x")
|
||||
keyBytes, err := hex.DecodeString(keyHex)
|
||||
if err != nil {
|
||||
// Fallback to base64 decode
|
||||
keyBytes, err = base64.StdEncoding.DecodeString(privateKeyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode private key: not valid hex or base64")
|
||||
}
|
||||
return nil, fmt.Errorf("decode private key: %w", err)
|
||||
}
|
||||
|
||||
// Accept both 32-byte (EVM) and 64-byte (Ed25519) keys
|
||||
// 32-byte EVM keys are padded to 64 bytes for Ed25519
|
||||
if len(keyBytes) == 32 {
|
||||
// Use as Ed25519 seed — append the public key part
|
||||
priv := ed25519.NewKeyFromSeed(keyBytes)
|
||||
keyBytes = []byte(priv)
|
||||
} else if len(keyBytes) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("invalid private key length: %d (expected 32 or %d)", len(keyBytes), ed25519.PrivateKeySize)
|
||||
privKey, err := crypto.ToECDSA(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert to ECDSA: %w", err)
|
||||
}
|
||||
|
||||
// If apiAddress not provided, derive from private key
|
||||
if apiAddress == "" {
|
||||
privKey := ed25519.PrivateKey(keyBytes)
|
||||
pubKey := privKey.Public().(ed25519.PublicKey)
|
||||
apiAddress = "0x" + hex.EncodeToString(pubKey)
|
||||
t := &HyperLiquidTrade{
|
||||
privateKey: privKey,
|
||||
mainAddress: mainAddress,
|
||||
configured: true,
|
||||
}
|
||||
|
||||
return &HyperLiquidTrade{
|
||||
PrivateKey: ed25519.PrivateKey(keyBytes),
|
||||
Address: mainAddress,
|
||||
APIAddress: apiAddress,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}, nil
|
||||
// Initialize SDK info (fetch meta for exchange)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.info = hl.NewInfo(ctx, hl.TestnetAPIURL, true, nil, nil, nil)
|
||||
if t.info == nil {
|
||||
return nil, fmt.Errorf("failed to create HL Info")
|
||||
}
|
||||
|
||||
// Wait briefly for meta to be fetched (Info fetches meta in constructor)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (h *HyperLiquidTrade) initExchange() error {
|
||||
if h.exchange != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h.configured {
|
||||
return fmt.Errorf("HyperLiquid not configured")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := h.fetchMeta(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch meta: %w", err)
|
||||
}
|
||||
|
||||
h.exchange = hl.NewExchange(
|
||||
ctx,
|
||||
h.privateKey,
|
||||
hl.TestnetAPIURL,
|
||||
meta,
|
||||
"", // vault
|
||||
h.mainAddress, // account address
|
||||
nil, // spot meta
|
||||
nil, // perp dex
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HyperLiquidTrade) fetchMeta(ctx context.Context) (*hl.Meta, error) {
|
||||
// Use info endpoint to get meta
|
||||
resp, err := h.postInfo(ctx, map[string]any{"type": "meta"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse as Meta struct
|
||||
var meta hl.Meta
|
||||
if err := json.Unmarshal(resp, &meta); err != nil {
|
||||
return nil, fmt.Errorf("parse meta: %w", err)
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func (h *HyperLiquidTrade) postInfo(ctx context.Context, payload map[string]any) ([]byte, error) {
|
||||
// Simple HTTP POST to info endpoint
|
||||
return nil, fmt.Errorf("not implemented via SDK - use Info directly")
|
||||
}
|
||||
|
||||
func (h *HyperLiquidTrade) IsConfigured() bool {
|
||||
return h.PrivateKey != nil && h.Address != ""
|
||||
return h.configured
|
||||
}
|
||||
|
||||
// PlaceMarketOrder places a market order on HyperLiquid.
|
||||
// coin: "BTC", side: "buy" or "sell", sz: order size in coin units (e.g. "0.001")
|
||||
// PlaceMarketOrder places a market (IOC) order on HyperLiquid.
|
||||
func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) {
|
||||
if !h.IsConfigured() {
|
||||
if !h.configured {
|
||||
return "", fmt.Errorf("HyperLiquid not configured")
|
||||
}
|
||||
|
||||
if err := h.initExchange(); err != nil {
|
||||
return "", fmt.Errorf("init exchange: %w", err)
|
||||
}
|
||||
|
||||
isBuy := side == "buy"
|
||||
action := HLOrderAction{
|
||||
Type: "order",
|
||||
Order: HLOrder{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Sz: sz,
|
||||
LimitPx: "1000000", // high limit price for market orders
|
||||
OrderType: "IOC",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
Grouping: "na",
|
||||
BrokerCode: 0,
|
||||
}
|
||||
|
||||
// Generate nonce (thread-safe)
|
||||
h.nonceMu.Lock()
|
||||
h.lastNonce++
|
||||
nonce := time.Now().UnixMilli()*1_000_000 + h.lastNonce%1_000_000
|
||||
h.nonceMu.Unlock()
|
||||
|
||||
// Sign the action
|
||||
sig, err := h.signAction(action, nonce)
|
||||
// Parse size from string to float64
|
||||
size, err := strconv.ParseFloat(sz, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign: %w", err)
|
||||
return "", fmt.Errorf("parse size %s: %w", sz, err)
|
||||
}
|
||||
|
||||
signed := HLSignedAction{
|
||||
Action: action,
|
||||
Nonce: nonce,
|
||||
Signature: sig,
|
||||
Address: h.APIAddress,
|
||||
}
|
||||
// Get current price for slippage calculation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
bodyJSON, _ := json.Marshal(signed)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.hyperliquid-testnet.xyz/exchange", strings.NewReader(string(bodyJSON)))
|
||||
allMids, err := h.fetchAllMids(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
return "", fmt.Errorf("fetch prices: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
priceStr, ok := allMids[coin]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("coin %s not found in allMids", coin)
|
||||
}
|
||||
midPx, _ := strconv.ParseFloat(priceStr, 64)
|
||||
|
||||
// Slippage price: buy = ask (mid * 1.02), sell = bid (mid * 0.98)
|
||||
var limitPx float64
|
||||
if isBuy {
|
||||
limitPx = midPx * 2.0 // aggressive buy
|
||||
} else {
|
||||
limitPx = midPx * 0.5 // aggressive sell
|
||||
}
|
||||
|
||||
result, err := h.exchange.MarketOpen(ctx, coin, isBuy, size, &limitPx, 0.05, nil, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Parse response
|
||||
var response json.RawMessage
|
||||
if err := json.Unmarshal(respBody, &response); err != nil {
|
||||
return "", fmt.Errorf("parse response: %s", string(respBody))
|
||||
return "", fmt.Errorf("market order: %w", err)
|
||||
}
|
||||
|
||||
// HL returns response as array, e.g. [{"response": {"type": "order", "data": ...}}]
|
||||
var results []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(respBody, &results); err != nil {
|
||||
// Maybe returns single object
|
||||
return string(respBody), nil
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return "", fmt.Errorf("empty response: %s", string(respBody))
|
||||
}
|
||||
respJSON, _ := json.Marshal(results[0])
|
||||
// Serialize response
|
||||
respJSON, _ := json.Marshal(result)
|
||||
return string(respJSON), nil
|
||||
}
|
||||
|
||||
// signAction generates an Ed25519 signature for a HyperLiquid action.
|
||||
// The signing hash is SHA512(action_json + nonce).
|
||||
func (h *HyperLiquidTrade) signAction(action HLOrderAction, nonce int64) (string, error) {
|
||||
actionJSON, err := json.Marshal(action)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func (h *HyperLiquidTrade) fetchAllMids(ctx context.Context) (map[string]string, error) {
|
||||
// Try to get allMids via the SDK's Info if available
|
||||
if h.info != nil {
|
||||
mids, err := h.info.AllMids(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mids, nil
|
||||
}
|
||||
|
||||
// HL signs: hash = sha512(action_json + nonce)
|
||||
nonceBig := big.NewInt(nonce)
|
||||
msg := string(actionJSON) + nonceBig.String()
|
||||
hash := sha512.Sum512([]byte(msg))
|
||||
|
||||
sig := ed25519.Sign(h.PrivateKey, hash[:])
|
||||
return "0x" + hex.EncodeToString(sig), nil
|
||||
return nil, fmt.Errorf("info not initialized")
|
||||
}
|
||||
|
||||
// GetHLSize calculates size for a given USD amount on HyperLiquid.
|
||||
// Uses szDecimals precision from HL's contract universe.
|
||||
// Returns size as a decimal string complying with HL precision.
|
||||
// Uses math.Floor to round DOWN to the nearest valid step, consistent with GetBitgetSize.
|
||||
func GetHLSize(coin string, amountUSD, price float64) string {
|
||||
sz := amountUSD / price // raw coin count
|
||||
sz := amountUSD / price
|
||||
switch coin {
|
||||
case "DOGE":
|
||||
sz = math.Floor(sz) // step=1, szDecimals=0
|
||||
sz = math.Floor(sz)
|
||||
if sz < 1 {
|
||||
sz = 1
|
||||
}
|
||||
return fmt.Sprintf("%.0f", sz)
|
||||
case "LINK":
|
||||
sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1
|
||||
sz = math.Floor(sz*10) / 10
|
||||
if sz < 0.1 {
|
||||
sz = 0.1
|
||||
}
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case "ONDO":
|
||||
sz = math.Floor(sz) // step=1, szDecimals=0
|
||||
sz = math.Floor(sz)
|
||||
if sz < 1 {
|
||||
sz = 1
|
||||
}
|
||||
return fmt.Sprintf("%.0f", sz)
|
||||
case "OP":
|
||||
sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1
|
||||
sz = math.Floor(sz*10) / 10
|
||||
if sz < 0.1 {
|
||||
sz = 0.1
|
||||
}
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case "WIF":
|
||||
sz = math.Floor(sz) // step=1, szDecimals=0
|
||||
sz = math.Floor(sz)
|
||||
if sz < 1 {
|
||||
sz = 1
|
||||
}
|
||||
return fmt.Sprintf("%.0f", sz)
|
||||
case "ARB":
|
||||
sz = math.Floor(sz*10) / 10 // step=0.1, szDecimals=1
|
||||
sz = math.Floor(sz*10) / 10
|
||||
if sz < 0.1 {
|
||||
sz = 0.1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user