mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-13 15:57:01 +08:00
feat(trader): wire Hyperliquid wallet and quick trade flow
- Add wallet API endpoints and exchange storage fields for Hyperliquid - Normalize quick trade order paths, symbols, and builder fee coverage - Add frontend wallet connect and quick trade helpers
This commit is contained in:
15
trader/hyperliquid/builder_fee_test.go
Normal file
15
trader/hyperliquid/builder_fee_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package hyperliquid
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultBuilderIsHardcodedToApprovedFeeTier(t *testing.T) {
|
||||
if defaultBuilder == nil {
|
||||
t.Fatal("defaultBuilder is nil")
|
||||
}
|
||||
if got := defaultBuilder.Builder; got != "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d" {
|
||||
t.Fatalf("defaultBuilder.Builder = %s, want hardcoded NOFX builder", got)
|
||||
}
|
||||
if got := defaultBuilder.Fee; got != 100 {
|
||||
t.Fatalf("defaultBuilder.Fee = %d, want hardcoded 100 for 0.1%%", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
hlprovider "nofx/provider/hyperliquid"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -58,54 +59,25 @@ var xyzDexAssets = map[string]bool{
|
||||
"XYZ100": true,
|
||||
}
|
||||
|
||||
// defaultBuilder is the builder info for order routing
|
||||
// Set to nil to avoid requiring builder fee approval
|
||||
//
|
||||
// var defaultBuilder = &hyperliquid.BuilderInfo{
|
||||
// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
// Fee: 10,
|
||||
// }
|
||||
var defaultBuilder *hyperliquid.BuilderInfo = nil
|
||||
|
||||
// isXyzDexAsset checks if a symbol is an xyz dex asset
|
||||
func isXyzDexAsset(symbol string) bool {
|
||||
// Remove common suffixes to get base symbol
|
||||
base := strings.ToUpper(symbol) // Convert to uppercase for case-insensitive matching
|
||||
for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} {
|
||||
if strings.HasSuffix(base, suffix) {
|
||||
base = strings.TrimSuffix(base, suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Remove xyz: prefix if present (case-insensitive)
|
||||
base = strings.TrimPrefix(base, "XYZ:")
|
||||
base = strings.TrimPrefix(base, "xyz:")
|
||||
return xyzDexAssets[base]
|
||||
// defaultBuilder is the builder info for order routing.
|
||||
// Users approve this builder during the top-right Hyperliquid connect flow before
|
||||
// their generated agent wallet is saved for live trading.
|
||||
var defaultBuilder = &hyperliquid.BuilderInfo{
|
||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
Fee: 100,
|
||||
}
|
||||
|
||||
// convertSymbolToHyperliquid converts standard symbol to Hyperliquid format
|
||||
// Example: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "silver" -> "xyz:SILVER"
|
||||
// isXyzDexAsset checks if a symbol is an xyz dex asset.
|
||||
// Keep this delegated to the provider map so newly listed xyz markets such as
|
||||
// SAMSUNG-USDC / SK-HYNIX-USDC cannot accidentally fall through as crypto.
|
||||
func isXyzDexAsset(symbol string) bool {
|
||||
return hlprovider.IsXYZAsset(symbol)
|
||||
}
|
||||
|
||||
// convertSymbolToHyperliquid converts standard/display symbols to Hyperliquid format.
|
||||
// Examples: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "SAMSUNG-USDC" -> "xyz:SMSN".
|
||||
func convertSymbolToHyperliquid(symbol string) string {
|
||||
// Convert to uppercase for consistent handling
|
||||
base := strings.ToUpper(symbol)
|
||||
|
||||
// Remove common suffixes to get base symbol
|
||||
for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} {
|
||||
if strings.HasSuffix(base, suffix) {
|
||||
base = strings.TrimSuffix(base, suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Remove xyz: prefix if present (case-insensitive, will be re-added if needed)
|
||||
if strings.HasPrefix(strings.ToLower(base), "xyz:") {
|
||||
base = base[4:] // Remove first 4 characters
|
||||
}
|
||||
|
||||
// Check if this is an xyz dex asset (stocks, forex, commodities)
|
||||
if isXyzDexAsset(base) {
|
||||
return "xyz:" + base
|
||||
}
|
||||
return base
|
||||
return hlprovider.FormatCoinForAPI(symbol)
|
||||
}
|
||||
|
||||
// absFloat returns absolute value of float
|
||||
|
||||
@@ -15,6 +15,28 @@ import (
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
)
|
||||
|
||||
func (t *HyperliquidTrader) placeOrderWithBuilderFee(order hyperliquid.CreateOrderRequest) error {
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return wrapBuilderFeeNotApproved(err)
|
||||
}
|
||||
|
||||
func isBuilderFeeNotApprovedError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "builder fee has not been approved")
|
||||
}
|
||||
|
||||
func wrapBuilderFeeNotApproved(err error) error {
|
||||
if isBuilderFeeNotApprovedError(err) {
|
||||
return fmt.Errorf("Hyperliquid builder fee is not approved for NOFX; reconnect Hyperliquid wallet and complete trading authorization: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// OpenLong opens a long position (supports both crypto and xyz dex)
|
||||
func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
// First cancel all pending orders for this coin
|
||||
@@ -71,7 +93,7 @@ func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage i
|
||||
ReduceOnly: false,
|
||||
}
|
||||
|
||||
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
err = t.placeOrderWithBuilderFee(order)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open long position: %w", err)
|
||||
}
|
||||
@@ -143,7 +165,7 @@ func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage
|
||||
ReduceOnly: false,
|
||||
}
|
||||
|
||||
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
err = t.placeOrderWithBuilderFee(order)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open short position: %w", err)
|
||||
}
|
||||
@@ -225,7 +247,7 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri
|
||||
ReduceOnly: true,
|
||||
}
|
||||
|
||||
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
err = t.placeOrderWithBuilderFee(order)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close long position: %w", err)
|
||||
}
|
||||
@@ -312,7 +334,7 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str
|
||||
ReduceOnly: true,
|
||||
}
|
||||
|
||||
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
err = t.placeOrderWithBuilderFee(order)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close short position: %w", err)
|
||||
}
|
||||
@@ -634,12 +656,13 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
|
||||
},
|
||||
}
|
||||
|
||||
// Create OrderAction (no builder to avoid requiring builder fee approval)
|
||||
// Create OrderAction with NOFX builder fee. Trader startup requires a persisted
|
||||
// builder approval flag before this live path can run.
|
||||
action := hyperliquid.OrderAction{
|
||||
Type: "order",
|
||||
Orders: []hyperliquid.OrderWire{orderWire},
|
||||
Grouping: "na",
|
||||
Builder: nil,
|
||||
Builder: defaultBuilder,
|
||||
}
|
||||
|
||||
// Sign the action
|
||||
@@ -727,7 +750,7 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
|
||||
if len(result.Response.Data.Statuses) > 0 {
|
||||
status := result.Response.Data.Statuses[0]
|
||||
if status.Error != nil {
|
||||
return fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error)
|
||||
return wrapBuilderFeeNotApproved(fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error))
|
||||
}
|
||||
if status.Filled != nil {
|
||||
logger.Infof("✅ xyz dex order filled: totalSz=%s avgPx=%s oid=%d",
|
||||
@@ -798,12 +821,13 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
|
||||
},
|
||||
}
|
||||
|
||||
// Create OrderAction (no builder to avoid requiring builder fee approval)
|
||||
// Create OrderAction with NOFX builder fee. Trader startup requires a persisted
|
||||
// builder approval flag before this live path can run.
|
||||
action := hyperliquid.OrderAction{
|
||||
Type: "order",
|
||||
Orders: []hyperliquid.OrderWire{orderWire},
|
||||
Grouping: "na",
|
||||
Builder: nil,
|
||||
Builder: defaultBuilder,
|
||||
}
|
||||
|
||||
// Sign the action
|
||||
@@ -885,7 +909,7 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
|
||||
if len(result.Response.Data.Statuses) > 0 {
|
||||
status := result.Response.Data.Statuses[0]
|
||||
if status.Error != nil {
|
||||
return fmt.Errorf("xyz dex %s order error: %s", tpsl, *status.Error)
|
||||
return wrapBuilderFeeNotApproved(fmt.Errorf("xyz dex %s order error: %s", tpsl, *status.Error))
|
||||
}
|
||||
if status.Resting != nil {
|
||||
logger.Infof("✅ xyz dex %s order placed: oid=%d", tpsl, status.Resting.Oid)
|
||||
@@ -934,7 +958,7 @@ func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quan
|
||||
ReduceOnly: true,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
err := t.placeOrderWithBuilderFee(order)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set stop loss: %w", err)
|
||||
}
|
||||
@@ -982,7 +1006,7 @@ func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, qu
|
||||
ReduceOnly: true,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
err := t.placeOrderWithBuilderFee(order)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set take profit: %w", err)
|
||||
}
|
||||
@@ -1029,7 +1053,7 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*type
|
||||
ReduceOnly: req.ReduceOnly,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
err := t.placeOrderWithBuilderFee(order)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user