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:
tinklefund
2026-05-25 01:24:58 +08:00
parent f37fc9f887
commit c7c003cc3c
16 changed files with 1731 additions and 253 deletions

View 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)
}
}

View File

@@ -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

View File

@@ -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)
}