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

@@ -26,84 +26,88 @@ type ExchangeConfig struct {
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
type SafeExchangeConfig struct {
ID string `json:"id"` // UUID
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
AccountName string `json:"account_name"` // User-defined account name
Name string `json:"name"` // Display name
Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
HasSecretKey bool `json:"has_secret_key"`
HasPassphrase bool `json:"has_passphrase"`
Testnet bool `json:"testnet,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
HasAsterPrivateKey bool `json:"has_aster_private_key"`
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
ID string `json:"id"` // UUID
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
AccountName string `json:"account_name"` // User-defined account name
Name string `json:"name"` // Display name
Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
HasSecretKey bool `json:"has_secret_key"`
HasPassphrase bool `json:"has_passphrase"`
Testnet bool `json:"testnet,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
HasAsterPrivateKey bool `json:"has_aster_private_key"`
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
}
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
return SafeExchangeConfig{
ID: exchange.ID,
ExchangeType: exchange.ExchangeType,
AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
HasAPIKey: exchange.APIKey != "",
HasSecretKey: exchange.SecretKey != "",
HasPassphrase: exchange.Passphrase != "",
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
LighterWalletAddr: exchange.LighterWalletAddr,
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
ID: exchange.ID,
ExchangeType: exchange.ExchangeType,
AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
HasAPIKey: exchange.APIKey != "",
HasSecretKey: exchange.SecretKey != "",
HasPassphrase: exchange.Passphrase != "",
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
LighterWalletAddr: exchange.LighterWalletAddr,
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
}
}
type UpdateExchangeConfigRequest struct {
Exchanges map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
} `json:"exchanges"`
}
// CreateExchangeRequest request structure for creating a new exchange account
type CreateExchangeRequest struct {
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
AccountName string `json:"account_name"` // User-defined account name
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
AccountName string `json:"account_name"` // User-defined account name
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
}
// handleGetExchangeConfigs Get exchange configurations
@@ -241,6 +245,11 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
if effectiveLighterWalletAddr == "" {
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
}
effectiveHyperliquidBuilderApproved := existing.HyperliquidBuilderApproved
if exchangeData.HyperliquidBuilderApproved != nil {
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
}
if missing := store.MissingRequiredExchangeCredentialFields(
existing.ExchangeType,
effectiveAPIKey,
@@ -266,7 +275,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
tradersToReload[t.ID] = true
}
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return
@@ -375,7 +384,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, true,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
)

View File

@@ -0,0 +1,308 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
defaultHyperliquidBuilderMaxFee = "0.1%"
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
)
type hyperliquidSubmitRequest struct {
Action map[string]any `json:"action" binding:"required"`
Nonce int64 `json:"nonce" binding:"required"`
Signature struct {
R string `json:"r" binding:"required"`
S string `json:"s" binding:"required"`
V int `json:"v"`
} `json:"signature" binding:"required"`
}
type hyperliquidConfigResponse struct {
BuilderAddress string `json:"builderAddress"`
BuilderMaxFee string `json:"builderMaxFee"`
Chain string `json:"chain"`
SignatureChain string `json:"signatureChainId"`
}
type hyperliquidAccountSummary struct {
Address string `json:"address"`
AccountValue float64 `json:"accountValue"`
Withdrawable float64 `json:"withdrawable"`
TotalMarginUsed float64 `json:"totalMarginUsed"`
UnrealizedPnl float64 `json:"unrealizedPnl"`
OpenPositions int `json:"openPositions"`
UpdatedAt int64 `json:"updatedAt"`
}
type hyperliquidClearinghouseState struct {
MarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
} `json:"marginSummary"`
CrossMarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
} `json:"crossMarginSummary"`
Withdrawable string `json:"withdrawable"`
AssetPositions []struct {
Position struct {
Szi string `json:"szi"`
UnrealizedPnl string `json:"unrealizedPnl"`
} `json:"position"`
} `json:"assetPositions"`
}
func hyperliquidBuilderAddress() string {
return defaultHyperliquidBuilderAddress
}
func hyperliquidBuilderMaxFee() string {
return defaultHyperliquidBuilderMaxFee
}
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
c.JSON(http.StatusOK, hyperliquidConfigResponse{
BuilderAddress: hyperliquidBuilderAddress(),
BuilderMaxFee: hyperliquidBuilderMaxFee(),
Chain: "Mainnet",
SignatureChain: "0x66eee",
})
}
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
if !isEVMAddress(address) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
return
}
requestBody := map[string]any{
"type": "clearinghouseState",
"user": address,
}
body, err := json.Marshal(requestBody)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance request"})
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid balance request"})
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the balance request", "status": resp.StatusCode})
return
}
var state hyperliquidClearinghouseState
if err := json.Unmarshal(respBody, &state); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
return
}
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
if accountValue == 0 {
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
}
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
if marginUsed == 0 {
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
}
var unrealizedPnl float64
openPositions := 0
for _, position := range state.AssetPositions {
size := parseFloatOrZero(position.Position.Szi)
if size != 0 {
openPositions++
}
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
}
c.JSON(http.StatusOK, hyperliquidAccountSummary{
Address: address,
AccountValue: accountValue,
Withdrawable: parseFloatOrZero(state.Withdrawable),
TotalMarginUsed: marginUsed,
UnrealizedPnl: unrealizedPnl,
OpenPositions: openPositions,
UpdatedAt: time.Now().UnixMilli(),
})
}
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
var req hyperliquidSubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
return
}
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
actionType, _ := req.Action["type"].(string)
switch actionType {
case "approveAgent":
if err := validateApproveAgentAction(req.Action); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
case "approveBuilderFee":
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
return
}
payload := map[string]any{
"action": req.Action,
"nonce": req.Nonce,
"signature": req.Signature,
}
body, err := json.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
return
}
client := &http.Client{Timeout: 20 * time.Second}
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
return
}
hlReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(hlReq)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var decoded any
if len(respBody) > 0 {
_ = json.Unmarshal(respBody, &decoded)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
}
func validateApproveAgentAction(action map[string]any) error {
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
return fmt.Errorf("missing agentAddress")
}
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
return fmt.Errorf("missing agentName")
}
return validateCommonHyperliquidSignedAction(action)
}
func validateApproveBuilderFeeAction(action map[string]any) error {
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
if builder != hyperliquidBuilderAddress() {
return fmt.Errorf("builder address mismatch")
}
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
return fmt.Errorf("builder max fee mismatch")
}
return validateCommonHyperliquidSignedAction(action)
}
func validateCommonHyperliquidSignedAction(action map[string]any) error {
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
return fmt.Errorf("invalid signatureChainId")
}
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
return fmt.Errorf("invalid hyperliquidChain")
}
if _, err := actionNonce(action); err != nil {
return err
}
return nil
}
func validateSubmittedNonce(action map[string]any, submitted int64) error {
actionValue, err := actionNonce(action)
if err != nil {
return err
}
if actionValue != submitted {
return fmt.Errorf("nonce mismatch")
}
return nil
}
func isEVMAddress(address string) bool {
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
return false
}
for _, char := range address[2:] {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return true
}
func parseFloatOrZero(value string) float64 {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0
}
return parsed
}
func actionNonce(action map[string]any) (int64, error) {
raw, ok := action["nonce"]
if !ok {
return 0, fmt.Errorf("missing nonce")
}
switch value := raw.(type) {
case float64:
return int64(value), nil
case int64:
return value, nil
case json.Number:
return value.Int64()
case string:
return strconv.ParseInt(value, 10, 64)
default:
return 0, fmt.Errorf("invalid nonce")
}
}

View File

@@ -80,6 +80,14 @@ func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, s
return "", ""
}
func isSupportedTraderSymbol(symbol string) bool {
normalized := strings.ToUpper(strings.TrimSpace(symbol))
if normalized == "" {
return true
}
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
@@ -173,12 +181,12 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
missing := missingExchangeFields(exchange)
if len(missing) > 0 {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」的配置还不完整缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
), "trader.create.exchange_missing_fields", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"missing_fields", strings.Join(missing, ", "),
)
fmt.Sprintf("交易所账户「%s」的配置还不完整缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
), "trader.create.exchange_missing_fields", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"missing_fields", strings.Join(missing, ", "),
)
}
switch exchange.ExchangeType {
@@ -186,12 +194,12 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
return "", "", nil
default:
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
"请改用当前版本支持的交易所账户后,再重新创建机器人",
), "trader.create.exchange_unsupported", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"exchange_type", exchange.ExchangeType,
)
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
"请改用当前版本支持的交易所账户后,再重新创建机器人",
), "trader.create.exchange_unsupported", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"exchange_type", exchange.ExchangeType,
)
}
}
@@ -327,14 +335,16 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
return
}
// Validate trading symbol format
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
// while standard crypto/perp markets keep the legacy USDT suffix format.
if req.TradingSymbols != "" {
symbols := strings.Split(req.TradingSymbols, ",")
for _, symbol := range symbols {
symbol = strings.TrimSpace(symbol)
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
if !isSupportedTraderSymbol(symbol) {
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持 USDT 结尾的合约交易对", symbol),
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持 USDT 合约或 Hyperliquid XYZ USDC 标的SYMBOL-USDC", symbol),
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
return
}
@@ -531,14 +541,14 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
if startupWarning == "" {
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
}
}
if startupWarning == "" {
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
startupWarning = describeTraderCreationWarning(req.Name, getErr)
}
}
@@ -546,11 +556,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
c.JSON(http.StatusCreated, gin.H{
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"is_running": false,
"startup_warning": startupWarning,
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"is_running": false,
"startup_warning": startupWarning,
})
}
@@ -767,6 +777,14 @@ func (s *Server) handleStartTrader(c *gin.Context) {
traderName = fullCfg.Trader.Name
}
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」的 Hyperliquid 交易授权尚未完成", traderName),
"请重新连接 Hyperliquid 钱包并完成交易授权后,再启动机器人",
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
// Check if trader exists in memory and if it's running
existingTrader, _ := s.traderManager.GetTrader(traderID)
if existingTrader != nil {

View File

@@ -92,6 +92,9 @@ func (s *Server) setupRoutes() {
// Wallet validation (no authentication required — used by frontend config form)
api.POST("/wallet/validate", s.handleWalletValidate)
api.POST("/wallet/generate", s.handleWalletGenerate)
s.route(api, "GET", "/hyperliquid/connect-config", "Get NOFX Hyperliquid builder authorization config", s.handleHyperliquidConnectConfig)
s.route(api, "GET", "/hyperliquid/account", "Get Hyperliquid account balance summary", s.handleHyperliquidAccount)
s.route(api, "POST", "/hyperliquid/submit-exchange", "Submit a user-signed Hyperliquid approval action", s.handleHyperliquidSubmitExchange)
// Crypto related endpoints (no authentication required, not exposed to bot)
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)

120
cmd/e2e_builder_fee/main.go Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"nofx/config"
nofxcrypto "nofx/crypto"
"nofx/store"
hltrader "nofx/trader/hyperliquid"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
type clearinghouseState struct {
CrossMarginSummary struct {
AccountValue string `json:"accountValue"`
} `json:"crossMarginSummary"`
Withdrawable string `json:"withdrawable"`
AssetPositions []struct {
Position struct {
Coin string `json:"coin"`
Szi string `json:"szi"`
EntryPx string `json:"entryPx"`
PositionValue string `json:"positionValue"`
} `json:"position"`
} `json:"assetPositions"`
}
func fetchState(wallet string) (*clearinghouseState, error) {
body := strings.NewReader(fmt.Sprintf(`{"type":"clearinghouseState","user":%q}`, wallet))
resp, err := http.Post("https://api.hyperliquid.xyz/info", "application/json", body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var state clearinghouseState
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
return nil, err
}
return &state, nil
}
func positionSize(state *clearinghouseState, coin string) float64 {
for _, ap := range state.AssetPositions {
if strings.EqualFold(ap.Position.Coin, coin) {
v, _ := strconv.ParseFloat(ap.Position.Szi, 64)
return v
}
}
return 0
}
func main() {
_ = godotenv.Load()
config.Init()
cryptoService, err := nofxcrypto.NewCryptoService()
if err != nil {
panic(err)
}
nofxcrypto.SetGlobalCryptoService(cryptoService)
cfg := config.Get()
st, err := store.NewWithConfig(store.DBConfig{Type: store.DBTypeSQLite, Path: cfg.DBPath})
if err != nil {
panic(err)
}
defer st.Close()
var ex store.Exchange
if err := st.GormDB().Where("exchange_type = ? AND enabled = ? AND hyperliquid_wallet_addr <> ''", "hyperliquid", true).First(&ex).Error; err != nil {
panic(fmt.Errorf("no enabled Hyperliquid exchange with wallet/private key found: %w", err))
}
if strings.TrimSpace(string(ex.APIKey)) == "" {
panic("Hyperliquid exchange has empty decrypted agent private key")
}
fmt.Printf("E2E exchange=%s account=%s wallet=%s testnet=%v builderApprovedFlag=%v\n", ex.ID, ex.AccountName, ex.HyperliquidWalletAddr, ex.Testnet, ex.HyperliquidBuilderApproved)
before, err := fetchState(ex.HyperliquidWalletAddr)
if err != nil {
panic(err)
}
fmt.Printf("BEFORE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", before.CrossMarginSummary.AccountValue, before.Withdrawable, positionSize(before, "xyz:HOOD"))
tr, err := hltrader.NewHyperliquidTrader(string(ex.APIKey), ex.HyperliquidWalletAddr, false, ex.HyperliquidUnifiedAcct)
if err != nil {
panic(err)
}
const symbol = "HOOD-USDC"
const qty = 0.15
fmt.Printf("OPEN_LONG symbol=%s qty=%.3f builderRequired=true\n", symbol, qty)
if _, err := tr.OpenLong(symbol, qty, 1); err != nil {
panic(fmt.Errorf("open long failed: %w", err))
}
time.Sleep(2 * time.Second)
mid, _ := fetchState(ex.HyperliquidWalletAddr)
pos := positionSize(mid, "xyz:HOOD")
fmt.Printf("AFTER_OPEN HOOD_szi=%.6f\n", pos)
closeQty := qty
if pos > 0 && pos < closeQty {
closeQty = pos
}
if closeQty > 0 {
fmt.Printf("CLOSE_LONG symbol=%s qty=%.6f builderRequired=true\n", symbol, closeQty)
if _, err := tr.CloseLong(symbol, closeQty); err != nil {
panic(fmt.Errorf("close long failed; manual intervention may be needed for %s size %.6f: %w", symbol, closeQty, err))
}
}
time.Sleep(2 * time.Second)
after, err := fetchState(ex.HyperliquidWalletAddr)
if err != nil {
panic(err)
}
fmt.Printf("AFTER_CLOSE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", after.CrossMarginSummary.AccountValue, after.Withdrawable, positionSize(after, "xyz:HOOD"))
fmt.Fprintln(os.Stdout, "E2E_BUILDER_FEE_REAL_XYZ_STOCK_TRADE_DONE")
}

View File

@@ -7,6 +7,7 @@ import (
"nofx/store"
"nofx/trader"
"sort"
"strings"
"sync"
"time"
)
@@ -410,6 +411,34 @@ func (tm *TraderManager) RemoveTrader(traderID string) {
}
}
func ensureHyperliquidNativeStrategy(traderName, exchangeType string, cfg *store.StrategyConfig) {
if cfg == nil || strings.ToLower(strings.TrimSpace(exchangeType)) != "hyperliquid" {
return
}
source := strings.ToLower(strings.TrimSpace(cfg.CoinSource.SourceType))
if source == "hyper_rank" || source == "static" || source == "hyper_all" || source == "hyper_main" {
return
}
logger.Warnf("⚠️ Trader %s uses legacy coin source %q on Hyperliquid; forcing native stock ranking to avoid crypto fallback", traderName, cfg.CoinSource.SourceType)
cfg.CoinSource.SourceType = "hyper_rank"
cfg.CoinSource.UseAI500 = false
cfg.CoinSource.UseOITop = false
cfg.CoinSource.UseOILow = false
cfg.CoinSource.UseHyperAll = false
cfg.CoinSource.UseHyperMain = false
if cfg.CoinSource.HyperRankCategory == "" {
cfg.CoinSource.HyperRankCategory = "stock"
}
if cfg.CoinSource.HyperRankDirection == "" {
cfg.CoinSource.HyperRankDirection = "gainers"
}
if cfg.CoinSource.HyperRankLimit <= 0 {
cfg.CoinSource.HyperRankLimit = 5
}
}
// LoadUserTradersFromStore loads traders from store for a specific user to memory
func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string) error {
tm.mu.Lock()
@@ -627,10 +656,15 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
return fmt.Errorf("failed to parse strategy config for trader %s: %w", traderCfg.Name, err)
}
logger.Infof("✓ Trader %s loaded strategy config: %s", traderCfg.Name, strategy.Name)
ensureHyperliquidNativeStrategy(traderCfg.Name, exchangeCfg.ExchangeType, strategyConfig)
} else {
return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name)
}
if exchangeCfg.ExchangeType == "hyperliquid" && !exchangeCfg.HyperliquidBuilderApproved {
return fmt.Errorf("Hyperliquid trading authorization is incomplete for exchange %s; reconnect Hyperliquid wallet and complete trading authorization before starting trader %s", exchangeCfg.AccountName, traderCfg.Name)
}
// Build AutoTraderConfig (ai500APIURL/oiTopAPIURL obtained from strategy config, used in StrategyEngine)
traderConfig := trader.AutoTraderConfig{
ID: traderCfg.ID,

View File

@@ -18,28 +18,29 @@ type ExchangeStore struct {
// Exchange exchange configuration
type Exchange struct {
ID string `gorm:"primaryKey" json:"id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
Enabled bool `gorm:"default:false" json:"enabled"`
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `gorm:"primaryKey" json:"id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
Enabled bool `gorm:"default:false" json:"enabled"`
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
HyperliquidBuilderApproved bool `gorm:"column:hyperliquid_builder_approved;default:false" json:"hyperliquidBuilderApproved"`
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Exchange) TableName() string { return "exchanges" }
@@ -55,7 +56,10 @@ func (s *ExchangeStore) initTables() error {
var tableExists int64
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'exchanges'`).Scan(&tableExists)
if tableExists > 0 {
// Still run data migrations
// Still run schema/data migrations
if err := s.ensureHyperliquidBuilderApprovedColumn(); err != nil {
logger.Warnf("Exchange builder approval column migration warning: %v", err)
}
s.migrateToMultiAccount()
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
if err := s.cleanupIncompleteExchangeConfigs(); err != nil {
@@ -70,6 +74,9 @@ func (s *ExchangeStore) initTables() error {
}
// Run migration to multi-account if needed
if err := s.ensureHyperliquidBuilderApprovedColumn(); err != nil {
logger.Warnf("Exchange builder approval column migration warning: %v", err)
}
if err := s.migrateToMultiAccount(); err != nil {
logger.Warnf("Multi-account migration warning: %v", err)
}
@@ -83,6 +90,13 @@ func (s *ExchangeStore) initTables() error {
return nil
}
func (s *ExchangeStore) ensureHyperliquidBuilderApprovedColumn() error {
if s.db.Migrator().HasColumn(&Exchange{}, "HyperliquidBuilderApproved") {
return nil
}
return s.db.Migrator().AddColumn(&Exchange{}, "HyperliquidBuilderApproved")
}
func (s *ExchangeStore) cleanupIncompleteExchangeConfigs() error {
var exchanges []Exchange
if err := s.db.Find(&exchanges).Error; err != nil {
@@ -226,7 +240,7 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
// Create creates a new exchange account with UUID
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool, hyperliquidBuilderApproved bool,
asterUser, asterSigner, asterPrivateKey,
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
@@ -245,26 +259,27 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
userID, exchangeType, accountName, id)
exchange := &Exchange{
ID: id,
ExchangeType: exchangeType,
AccountName: accountName,
UserID: userID,
Name: name,
Type: typ,
Enabled: true,
APIKey: crypto.EncryptedString(apiKey),
SecretKey: crypto.EncryptedString(secretKey),
Passphrase: crypto.EncryptedString(passphrase),
Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr,
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
AsterUser: asterUser,
AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
LighterWalletAddr: lighterWalletAddr,
LighterPrivateKey: crypto.EncryptedString(lighterPrivateKey),
LighterAPIKeyPrivateKey: crypto.EncryptedString(lighterApiKeyPrivateKey),
LighterAPIKeyIndex: lighterApiKeyIndex,
ID: id,
ExchangeType: exchangeType,
AccountName: accountName,
UserID: userID,
Name: name,
Type: typ,
Enabled: true,
APIKey: crypto.EncryptedString(apiKey),
SecretKey: crypto.EncryptedString(secretKey),
Passphrase: crypto.EncryptedString(passphrase),
Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr,
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
HyperliquidBuilderApproved: exchangeType == "hyperliquid" && hyperliquidBuilderApproved,
AsterUser: asterUser,
AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
LighterWalletAddr: lighterWalletAddr,
LighterPrivateKey: crypto.EncryptedString(lighterPrivateKey),
LighterAPIKeyPrivateKey: crypto.EncryptedString(lighterApiKeyPrivateKey),
LighterAPIKeyIndex: lighterApiKeyIndex,
}
if err := s.db.Create(exchange).Error; err != nil {
@@ -275,21 +290,22 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
// Update updates exchange configuration by UUID
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool, hyperliquidBuilderApproved bool,
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s", userID, id)
updates := map[string]interface{}{
"enabled": true,
"testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
"aster_user": asterUser,
"aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now().UTC(),
"enabled": true,
"testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
"hyperliquid_builder_approved": hyperliquidBuilderApproved,
"aster_user": asterUser,
"aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now().UTC(),
}
// Only update encrypted fields if not empty
@@ -360,7 +376,7 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
// Check if this is an old-style ID (exchange type as ID)
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
hyperliquidWalletAddr, true, // Default to Unified Account mode
hyperliquidWalletAddr, true, false, // Default to Unified Account mode; builder approval must be explicit
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return err
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
"nofx/wallet"
"strings"
@@ -207,6 +208,7 @@ func (at *AutoTrader) runCycle() error {
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
sortedDecisions = at.filterDecisionsToStrategyUniverse(sortedDecisions, ctx)
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
for i, d := range sortedDecisions {
@@ -286,6 +288,52 @@ func (at *AutoTrader) runCycle() error {
return nil
}
func normalizeUniverseSymbol(symbol string) string {
return market.Normalize(strings.TrimSpace(symbol))
}
func isOpenDecision(action string) bool {
a := strings.ToLower(strings.TrimSpace(action))
return a == "open_long" || a == "open_short"
}
func (at *AutoTrader) filterDecisionsToStrategyUniverse(decisions []kernel.Decision, ctx *kernel.Context) []kernel.Decision {
if ctx == nil || len(decisions) == 0 {
return decisions
}
allowed := make(map[string]bool, len(ctx.CandidateCoins))
for _, coin := range ctx.CandidateCoins {
allowed[normalizeUniverseSymbol(coin.Symbol)] = true
}
positions := make(map[string]bool, len(ctx.Positions))
for _, pos := range ctx.Positions {
positions[normalizeUniverseSymbol(pos.Symbol)] = true
}
filtered := make([]kernel.Decision, 0, len(decisions))
for _, d := range decisions {
sym := normalizeUniverseSymbol(d.Symbol)
if sym == "" || sym == "ALL" {
filtered = append(filtered, d)
continue
}
if allowed[sym] || positions[sym] {
filtered = append(filtered, d)
continue
}
if isOpenDecision(d.Action) {
at.logWarnf("🚫 Blocked AI %s for %s: symbol is outside strategy candidate universe", d.Action, d.Symbol)
} else {
at.logWarnf("🚫 Dropped AI decision for %s: symbol is outside strategy candidate universe", d.Symbol)
}
}
return filtered
}
// buildTradingContext builds trading context
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// 1. Get account information

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

View File

@@ -0,0 +1,707 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, Copy, ExternalLink, Loader2, RefreshCw, Shield, Wallet, X } from 'lucide-react'
import { toast } from 'sonner'
import { api } from '../../lib/api'
import type { HyperliquidAccountSummary } from '../../lib/api/wallet'
import type { Language } from '../../i18n/translations'
declare global {
interface Window {
ethereum?: WalletProvider & { providers?: WalletProvider[] }
}
}
type WalletProvider = {
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>
on?: (event: string, handler: (...args: unknown[]) => void) => void
removeListener?: (event: string, handler: (...args: unknown[]) => void) => void
isMetaMask?: boolean
isRabby?: boolean
isOkxWallet?: boolean
isCoinbaseWallet?: boolean
isTrust?: boolean
isPhantom?: boolean
isBackpack?: boolean
isBraveWallet?: boolean
isExodus?: boolean
isFrame?: boolean
}
type StepStatus = 'pending' | 'active' | 'done' | 'error'
interface HyperliquidWalletConnectProps {
language: Language
isLoggedIn: boolean
variant?: 'dropdown' | 'inline'
}
interface FlowState {
mainWallet?: string
agentAddress?: string
agentPrivateKey?: string
agentApproved?: boolean
builderApproved?: boolean
savedExchangeId?: string
reusedSavedExchange?: boolean
}
const STORAGE_KEY = 'nofx.hyperliquid.connection.v6'
const AGENT_NAME = 'NOFX Agent'
const HYPERLIQUID_BUILDER_ADDRESS = '0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d'
const HYPERLIQUID_BUILDER_MAX_FEE = '0.1%'
function shortAddress(address?: string) {
if (!address) return ''
return `${address.slice(0, 6)}${address.slice(-4)}`
}
function copy(text: string, label: string) {
navigator.clipboard?.writeText(text).then(
() => toast.success(`${label} copied`),
() => toast.error('Copy failed')
)
}
function normalizeAddress(address: string) {
return address.trim().toLowerCase()
}
function getWalletProviders(): WalletProvider[] {
const injected = window.ethereum
if (!injected) return []
const providers = Array.isArray(injected.providers) && injected.providers.length > 0
? injected.providers
: [injected]
const seen = new Set<WalletProvider>()
return providers.filter((provider) => {
if (!provider || seen.has(provider)) return false
seen.add(provider)
return true
})
}
function getPreferredWalletProvider(): WalletProvider | undefined {
const providers = getWalletProviders()
return providers.find((provider) => provider.isRabby)
|| providers.find((provider) => provider.isMetaMask)
|| providers.find((provider) => provider.isCoinbaseWallet)
|| providers.find((provider) => provider.isPhantom)
|| providers.find((provider) => provider.isBraveWallet)
|| providers.find((provider) => provider.isBackpack)
|| providers.find((provider) => provider.isOkxWallet)
|| providers.find((provider) => provider.isTrust)
|| providers.find((provider) => provider.isExodus)
|| providers.find((provider) => provider.isFrame)
|| providers[0]
}
function walletSupportLabel(language: Language) {
return language === 'zh'
? '支持 MetaMask、Rabby、Coinbase、Phantom、Brave、Backpack、OKX、Trust 等 EVM 钱包。'
: 'Supports MetaMask, Rabby, Coinbase Wallet, Phantom, Brave, Backpack, OKX, Trust and other EVM wallets.'
}
function formatUSDC(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
}
function formatSignedUSDC(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
const sign = value > 0 ? '+' : ''
return `${sign}${formatUSDC(value)}`
}
function splitSignature(signature: string) {
const hex = signature.startsWith('0x') ? signature.slice(2) : signature
if (hex.length !== 130) {
throw new Error('Invalid wallet signature length')
}
const v = parseInt(hex.slice(128, 130), 16)
return {
r: `0x${hex.slice(0, 64)}`,
s: `0x${hex.slice(64, 128)}`,
v: v < 27 ? v + 27 : v,
}
}
function buildTypedData(primaryType: string, fields: { name: string; type: string }[], message: Record<string, unknown>) {
return {
domain: {
name: 'HyperliquidSignTransaction',
version: '1',
chainId: 421614,
verifyingContract: '0x0000000000000000000000000000000000000000',
},
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
[primaryType]: fields,
},
primaryType,
message,
}
}
function getSavedState(): FlowState {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
function saveState(state: FlowState) {
const safeState = { ...state }
if (safeState.savedExchangeId) {
delete safeState.agentPrivateKey
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(safeState))
}
export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'dropdown' }: HyperliquidWalletConnectProps) {
const inline = variant === 'inline'
const [open, setOpen] = useState(inline)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [state, setState] = useState<FlowState>(() => getSavedState())
const [account, setAccount] = useState<HyperliquidAccountSummary | null>(null)
const [balanceLoading, setBalanceLoading] = useState(false)
const [balanceError, setBalanceError] = useState('')
const text = useMemo(
() => ({
title: language === 'zh' ? 'Hyperliquid 钱包' : 'Hyperliquid Wallet',
connect: language === 'zh' ? '连接 Hyperliquid' : 'Connect Hyperliquid',
connected: language === 'zh' ? '已连接' : 'Connected',
mainWallet: language === 'zh' ? 'EVM 主钱包' : 'EVM main wallet',
generateAgent: language === 'zh' ? '生成 NOFX Agent 钱包' : 'Generate NOFX agent wallet',
approveAgent: language === 'zh' ? '授权 Agent 交易' : 'Authorize agent trading',
approveBuilder: language === 'zh' ? '完成交易授权' : 'Finalize trading authorization',
save: language === 'zh' ? '保存到 NOFX' : 'Save to NOFX',
done: language === 'zh' ? '流程已完成' : 'Flow complete',
balance: language === 'zh' ? 'Hyperliquid 余额' : 'Hyperliquid balance',
withdrawable: language === 'zh' ? '可用' : 'Withdrawable',
equity: language === 'zh' ? '权益' : 'Equity',
marginUsed: language === 'zh' ? '已用保证金' : 'Margin used',
unrealizedPnl: language === 'zh' ? '未实现盈亏' : 'Unrealized PnL',
refresh: language === 'zh' ? '刷新' : 'Refresh',
noCustody: language === 'zh' ? '资金保留在你的 Hyperliquid 账户NOFX 只保存已授权 Agent 钱包。' : 'Funds stay in your Hyperliquid account; NOFX only stores the authorized agent wallet.',
}),
[language]
)
useEffect(() => {
saveState(state)
}, [state])
useEffect(() => {
if (!isLoggedIn || !state.mainWallet) return
let cancelled = false
api.getExchangeConfigs()
.then((configs) => {
if (cancelled) return
const existing = configs.find((exchange) =>
exchange.exchange_type === 'hyperliquid' &&
normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(state.mainWallet!)
)
if (!existing) return
setState((prev) => {
if (normalizeAddress(prev.mainWallet || '') !== normalizeAddress(state.mainWallet!)) return prev
const serverBuilderApproved = Boolean(existing.hyperliquidBuilderApproved)
if (
prev.savedExchangeId === existing.id &&
prev.agentApproved === true &&
prev.builderApproved === serverBuilderApproved &&
prev.reusedSavedExchange === true
) {
return prev
}
return {
...prev,
agentPrivateKey: undefined,
agentApproved: true,
builderApproved: serverBuilderApproved,
savedExchangeId: existing.id,
reusedSavedExchange: true,
}
})
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [isLoggedIn, state.mainWallet])
useEffect(() => {
const handler = (accounts: unknown) => {
const next = Array.isArray(accounts) && typeof accounts[0] === 'string' ? normalizeAddress(accounts[0]) : undefined
if (next) {
setState((prev) => ({ ...prev, mainWallet: next }))
}
}
const provider = getPreferredWalletProvider()
provider?.on?.('accountsChanged', handler)
return () => provider?.removeListener?.('accountsChanged', handler)
}, [])
useEffect(() => {
if (open && state.mainWallet) {
void refreshBalance(state.mainWallet)
}
}, [open, state.mainWallet])
async function refreshBalance(address = state.mainWallet) {
if (!address) return
setBalanceLoading(true)
setBalanceError('')
try {
const summary = await api.getHyperliquidAccount(address)
setAccount(summary)
} catch (err) {
setAccount(null)
setBalanceError(err instanceof Error ? err.message : 'Failed to load Hyperliquid balance')
} finally {
setBalanceLoading(false)
}
}
async function reuseSavedExchangeIfPresent(address: string) {
if (!isLoggedIn) return false
try {
const configs = await api.getExchangeConfigs()
const existing = configs.find((exchange) =>
exchange.exchange_type === 'hyperliquid' &&
normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(address)
)
if (!existing) return false
setState((prev) => ({
...prev,
mainWallet: normalizeAddress(address),
agentAddress: prev.mainWallet === normalizeAddress(address) ? prev.agentAddress : undefined,
agentPrivateKey: undefined,
agentApproved: true,
// Existing configs default to false in the backend unless the exact
// approveBuilderFee flow has already persisted a successful approval.
builderApproved: Boolean(existing.hyperliquidBuilderApproved),
savedExchangeId: existing.id,
reusedSavedExchange: true,
}))
return true
} catch {
return false
}
}
const savedReady = Boolean(state.savedExchangeId)
const agentReady = Boolean(state.agentAddress || savedReady)
const agentApprovedReady = Boolean(state.agentApproved || savedReady)
const builderReady = Boolean(state.builderApproved)
const steps: { key: keyof FlowState; label: string; status: StepStatus }[] = [
{ key: 'mainWallet', label: text.mainWallet, status: state.mainWallet ? 'done' : 'active' },
{ key: 'agentAddress', label: text.generateAgent, status: agentReady ? 'done' : state.mainWallet ? 'active' : 'pending' },
{ key: 'agentApproved', label: text.approveAgent, status: agentApprovedReady ? 'done' : agentReady ? 'active' : 'pending' },
{ key: 'builderApproved', label: text.approveBuilder, status: builderReady ? 'done' : agentApprovedReady ? 'active' : 'pending' },
{ key: 'savedExchangeId', label: text.save, status: state.savedExchangeId ? 'done' : builderReady ? 'active' : 'pending' },
]
const complete = Boolean(state.mainWallet && state.savedExchangeId && state.builderApproved)
async function connectWallet() {
setError('')
const provider = getPreferredWalletProvider()
if (!provider) {
setError(language === 'zh' ? '未检测到 EVM 钱包,请安装 MetaMask / Rabby / OKX / Coinbase Wallet。' : 'No EVM wallet detected. Install MetaMask, Rabby, OKX or Coinbase Wallet.')
return
}
setBusy(true)
try {
const accounts = await provider.request({ method: 'eth_requestAccounts' })
const first = Array.isArray(accounts) && typeof accounts[0] === 'string' ? accounts[0] : ''
if (!first) throw new Error('Wallet returned no account')
const normalized = normalizeAddress(first)
setState((prev) => {
const sameWallet = prev.mainWallet === normalized
return {
...prev,
mainWallet: normalized,
agentAddress: sameWallet ? prev.agentAddress : undefined,
agentPrivateKey: sameWallet ? prev.agentPrivateKey : undefined,
agentApproved: sameWallet ? prev.agentApproved : false,
builderApproved: sameWallet ? prev.builderApproved : false,
savedExchangeId: sameWallet ? prev.savedExchangeId : undefined,
reusedSavedExchange: sameWallet ? prev.reusedSavedExchange : false,
}
})
await Promise.all([
refreshBalance(normalized),
reuseSavedExchangeIfPresent(normalized),
])
} catch (err) {
setError(err instanceof Error ? err.message : 'Wallet connection failed')
} finally {
setBusy(false)
}
}
async function generateAgentWallet() {
setError('')
if (!state.mainWallet) return
setBusy(true)
try {
const wallet = await api.generateWallet()
setState((prev) => ({
...prev,
agentAddress: normalizeAddress(wallet.address),
agentPrivateKey: wallet.private_key,
agentApproved: false,
builderApproved: false,
savedExchangeId: undefined,
}))
toast.success('NOFX agent wallet generated')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate agent wallet')
} finally {
setBusy(false)
}
}
async function signAndSubmit(action: Record<string, unknown>, primaryType: string, fields: { name: string; type: string }[]) {
const provider = getPreferredWalletProvider()
if (!provider || !state.mainWallet) throw new Error('Wallet is not connected')
const typedData = buildTypedData(primaryType, fields, action)
const raw = await provider.request({
method: 'eth_signTypedData_v4',
params: [state.mainWallet, JSON.stringify(typedData)],
})
if (typeof raw !== 'string') throw new Error('Wallet returned an invalid signature')
const signature = splitSignature(raw)
await api.submitHyperliquidApproval(action, Number(action.nonce), signature)
}
async function approveAgent() {
setError('')
if (!state.agentAddress) return
setBusy(true)
try {
const nonce = Date.now()
const action = {
type: 'approveAgent',
signatureChainId: '0x66eee',
hyperliquidChain: 'Mainnet',
agentAddress: state.agentAddress,
agentName: AGENT_NAME,
nonce,
}
await signAndSubmit(action, 'HyperliquidTransaction:ApproveAgent', [
{ name: 'hyperliquidChain', type: 'string' },
{ name: 'agentAddress', type: 'address' },
{ name: 'agentName', type: 'string' },
{ name: 'nonce', type: 'uint64' },
])
setState((prev) => ({ ...prev, agentApproved: true, savedExchangeId: undefined }))
toast.success('Hyperliquid agent approved')
} catch (err) {
setError(err instanceof Error ? err.message : 'Agent approval failed')
} finally {
setBusy(false)
}
}
async function approveBuilderFee() {
setError('')
setBusy(true)
try {
const nonce = Date.now()
const action = {
type: 'approveBuilderFee',
signatureChainId: '0x66eee',
hyperliquidChain: 'Mainnet',
maxFeeRate: HYPERLIQUID_BUILDER_MAX_FEE,
builder: normalizeAddress(HYPERLIQUID_BUILDER_ADDRESS),
nonce,
}
await signAndSubmit(action, 'HyperliquidTransaction:ApproveBuilderFee', [
{ name: 'hyperliquidChain', type: 'string' },
{ name: 'maxFeeRate', type: 'string' },
{ name: 'builder', type: 'address' },
{ name: 'nonce', type: 'uint64' },
])
if (isLoggedIn && state.savedExchangeId && state.mainWallet) {
await api.updateExchangeConfigsEncrypted({
exchanges: {
[state.savedExchangeId]: {
enabled: true,
api_key: '',
secret_key: '',
passphrase: '',
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_builder_approved: true,
testnet: false,
},
},
})
}
setState((prev) => ({
...prev,
builderApproved: true,
savedExchangeId: prev.reusedSavedExchange ? prev.savedExchangeId : undefined,
}))
toast.success(language === 'zh' ? '交易授权已完成' : 'Trading authorization finalized')
} catch (err) {
setError(err instanceof Error ? err.message : (language === 'zh' ? '交易授权失败' : 'Trading authorization failed'))
} finally {
setBusy(false)
}
}
async function saveExchange() {
setError('')
if (!isLoggedIn) {
setError(language === 'zh' ? '请先登录 NOFX再保存 Agent 钱包用于交易。' : 'Please sign in before saving the agent wallet for trading.')
return
}
if (!state.mainWallet || !state.builderApproved) return
setBusy(true)
try {
const existing = (await api.getExchangeConfigs()).find((exchange) =>
exchange.exchange_type === 'hyperliquid' &&
normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(state.mainWallet!)
)
if (existing) {
await api.updateExchangeConfigsEncrypted({
exchanges: {
[existing.id]: {
enabled: true,
api_key: state.agentPrivateKey || '',
secret_key: '',
passphrase: '',
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_builder_approved: true,
testnet: false,
},
},
})
setState((prev) => ({ ...prev, agentPrivateKey: undefined, savedExchangeId: existing.id, reusedSavedExchange: !state.agentPrivateKey, builderApproved: true }))
toast.success(state.agentPrivateKey ? 'Hyperliquid account updated in NOFX' : 'Existing Hyperliquid account authorization updated')
return
}
if (!state.agentPrivateKey) {
throw new Error('Generate and authorize a new agent wallet before saving')
}
const result = await api.createExchangeEncrypted({
exchange_type: 'hyperliquid',
account_name: `Hyperliquid ${shortAddress(state.mainWallet)}`,
enabled: true,
api_key: state.agentPrivateKey,
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_builder_approved: true,
testnet: false,
})
setState((prev) => ({ ...prev, agentPrivateKey: undefined, savedExchangeId: result.id, reusedSavedExchange: false }))
toast.success('Hyperliquid account saved to NOFX')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save Hyperliquid account')
} finally {
setBusy(false)
}
}
function resetTradingAuthorization() {
setOpen(true)
setError('')
setState((prev) => ({
...prev,
agentApproved: prev.agentApproved || Boolean(prev.savedExchangeId),
builderApproved: false,
reusedSavedExchange: Boolean(prev.savedExchangeId) || prev.reusedSavedExchange,
}))
}
function resetFlow() {
window.localStorage.removeItem(STORAGE_KEY)
setState({})
setAccount(null)
setBalanceError('')
setError('')
}
return (
<div className={inline ? 'relative w-full' : 'relative'}>
{!inline && (
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-bold transition-all border ${
complete
? 'bg-emerald-500/10 border-emerald-400/30 text-emerald-300'
: 'bg-nofx-gold/10 border-nofx-gold/30 text-nofx-gold hover:bg-nofx-gold/20'
}`}
>
<Wallet className="w-4 h-4" />
<span>{complete ? shortAddress(state.mainWallet) : text.connect}</span>
<ChevronDown className="w-4 h-4" />
</button>
)}
{(open || inline) && (
<div className={`${inline ? 'relative w-full' : 'absolute right-0 top-full mt-2 w-[420px] shadow-2xl shadow-black/50'} rounded-2xl border border-nofx-gold/20 bg-[#11151B] z-[80] overflow-hidden`}>
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div>
<div className="font-bold text-white">{text.title}</div>
<div className="text-xs text-nofx-text-muted mt-1">{text.noCustody}</div>
<div className="text-[11px] text-nofx-gold/80 mt-1">{walletSupportLabel(language)}</div>
</div>
{!inline && (
<button type="button" onClick={() => setOpen(false)} className="p-1 rounded hover:bg-white/10 text-zinc-500">
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="p-4 space-y-4">
<div className="space-y-2">
{steps.map((step, index) => (
<div key={step.key} className="flex items-center gap-3 text-sm">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
step.status === 'done'
? 'bg-emerald-400 text-black'
: step.status === 'active'
? 'bg-nofx-gold text-black'
: 'bg-zinc-800 text-zinc-500'
}`}
>
{step.status === 'done' ? <Check className="w-3.5 h-3.5" /> : index + 1}
</div>
<span className={step.status === 'pending' ? 'text-zinc-500' : 'text-zinc-200'}>{step.label}</span>
</div>
))}
</div>
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-300">
{error}
</div>
)}
<div className="rounded-xl border border-white/10 bg-black/25 p-3 space-y-2 text-xs">
{state.mainWallet && (
<div className="flex items-center justify-between gap-3">
<span className="text-zinc-500">Main</span>
<button type="button" onClick={() => copy(state.mainWallet!, 'Main wallet')} className="font-mono text-zinc-200 hover:text-nofx-gold flex items-center gap-1">
{shortAddress(state.mainWallet)} <Copy className="w-3 h-3" />
</button>
</div>
)}
{state.agentAddress && (
<div className="flex items-center justify-between gap-3">
<span className="text-zinc-500">Agent</span>
<button type="button" onClick={() => copy(state.agentAddress!, 'Agent wallet')} className="font-mono text-zinc-200 hover:text-nofx-gold flex items-center gap-1">
{shortAddress(state.agentAddress)} <Copy className="w-3 h-3" />
</button>
</div>
)}
<div className="flex items-center justify-between gap-3">
<span className="text-zinc-500">Network</span>
<span className="font-mono text-zinc-300">Hyperliquid Mainnet</span>
</div>
</div>
{state.mainWallet && (
<div className="rounded-xl border border-nofx-gold/20 bg-nofx-gold/5 p-3 space-y-3 text-xs">
<div className="flex items-center justify-between gap-3">
<span className="font-bold text-zinc-100">{text.balance}</span>
<button
type="button"
onClick={() => void refreshBalance()}
disabled={balanceLoading}
className="flex items-center gap-1 text-zinc-400 hover:text-nofx-gold disabled:opacity-60"
>
<RefreshCw className={`w-3 h-3 ${balanceLoading ? 'animate-spin' : ''}`} />
{text.refresh}
</button>
</div>
{balanceError ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-2 text-red-300">{balanceError}</div>
) : (
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-black/25 p-2">
<div className="text-zinc-500">{text.withdrawable}</div>
<div className="mt-1 font-mono text-sm font-bold text-emerald-300">{balanceLoading && !account ? 'Loading…' : `${formatUSDC(account?.withdrawable)} USDC`}</div>
</div>
<div className="rounded-lg bg-black/25 p-2">
<div className="text-zinc-500">{text.equity}</div>
<div className="mt-1 font-mono text-sm font-bold text-zinc-100">{balanceLoading && !account ? 'Loading…' : `${formatUSDC(account?.accountValue)} USDC`}</div>
</div>
<div className="rounded-lg bg-black/25 p-2">
<div className="text-zinc-500">{text.marginUsed}</div>
<div className="mt-1 font-mono text-sm font-bold text-zinc-100">{formatUSDC(account?.totalMarginUsed)} USDC</div>
</div>
<div className="rounded-lg bg-black/25 p-2">
<div className="text-zinc-500">{text.unrealizedPnl}</div>
<div className={`mt-1 font-mono text-sm font-bold ${(account?.unrealizedPnl ?? 0) >= 0 ? 'text-emerald-300' : 'text-red-300'}`}>{formatSignedUSDC(account?.unrealizedPnl)} USDC</div>
</div>
</div>
)}
</div>
)}
<div className="grid grid-cols-1 gap-2">
{!state.mainWallet && <ActionButton busy={busy} onClick={connectWallet} label={text.connect} />}
{state.mainWallet && !agentReady && <ActionButton busy={busy} onClick={generateAgentWallet} label={text.generateAgent} />}
{agentReady && !agentApprovedReady && <ActionButton busy={busy} onClick={approveAgent} label={text.approveAgent} />}
{agentApprovedReady && !builderReady && <ActionButton busy={busy} onClick={approveBuilderFee} label={text.approveBuilder} />}
{builderReady && !state.savedExchangeId && <ActionButton busy={busy} onClick={saveExchange} label={text.save} />}
{complete && (
<>
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 p-3 text-sm text-emerald-200 flex items-center gap-2">
<Shield className="w-4 h-4" /> {text.done}
</div>
<button
type="button"
onClick={resetTradingAuthorization}
className="w-full flex items-center justify-center gap-2 rounded-xl border border-nofx-gold/30 bg-nofx-gold/10 px-4 py-3 text-sm font-bold text-nofx-gold transition hover:bg-nofx-gold/20"
>
{language === 'zh' ? '重新授权交易' : 'Re-authorize trading'}
</button>
</>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-white/10">
<a href="https://app.hyperliquid.xyz/" target="_blank" rel="noopener noreferrer" className="text-xs text-zinc-500 hover:text-nofx-gold flex items-center gap-1">
Open Hyperliquid <ExternalLink className="w-3 h-3" />
</a>
<button type="button" onClick={resetFlow} className="text-xs text-zinc-500 hover:text-red-300">
Reset
</button>
</div>
</div>
</div>
)}
</div>
)
}
function ActionButton({ busy, onClick, label }: { busy: boolean; onClick: () => void; label: string }) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-nofx-gold px-4 py-3 text-sm font-bold text-black transition hover:bg-yellow-400 disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{label}
</button>
)
}

View File

@@ -7,6 +7,7 @@ import type {
CreateTraderRequest,
AIModel,
Exchange,
ExchangeAccountState,
} from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
@@ -44,6 +45,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [editingTrader, setEditingTrader] = useState<any>(null)
const [allModels, setAllModels] = useState<AIModel[]>([])
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [exchangeAccountStates, setExchangeAccountStates] = useState<Record<string, ExchangeAccountState>>({})
const [isExchangeAccountStatesLoading, setIsExchangeAccountStatesLoading] = useState(false)
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
@@ -56,18 +59,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
return
}
const [
modelConfigs,
exchangeConfigs,
models,
] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
])
setAllModels(modelConfigs)
setAllExchanges(exchangeConfigs)
setSupportedModels(models)
setIsExchangeAccountStatesLoading(true)
try {
const [
modelConfigs,
exchangeConfigs,
models,
accountStateResponse,
] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
api.getExchangeAccountState().catch(() => ({ states: {} })),
])
setAllModels(modelConfigs)
setAllExchanges(exchangeConfigs)
setExchangeAccountStates(accountStateResponse.states || {})
setSupportedModels(models)
} finally {
setIsExchangeAccountStatesLoading(false)
}
}
// Toggle wallet address visibility for a trader
@@ -96,6 +107,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
})
}
// Copy wallet address to clipboard
const handleCopyAddress = async (id: string, address: string) => {
try {
@@ -691,6 +703,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<ConfigStatusGrid
configuredModels={configuredModels}
configuredExchanges={configuredExchanges}
exchangeAccountStates={exchangeAccountStates}
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
visibleExchangeAddresses={visibleExchangeAddresses}
copiedId={copiedId}
language={language}

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'
import type { Exchange } from '../../types'
import { t, type Language } from '../../i18n/translations'
import { api } from '../../lib/api'
import { useAuth } from '../../contexts/AuthContext'
import { HyperliquidWalletConnect } from '../common/HyperliquidWalletConnect'
import { getExchangeIcon } from '../common/ExchangeIcons'
import {
TwoStageKeyModal,
@@ -151,6 +153,7 @@ export function ExchangeConfigModal({
onClose,
language,
}: ExchangeConfigModalProps) {
const { user } = useAuth()
// Step: 0 = select exchange, 1 = configure
const [currentStep, setCurrentStep] = useState(editingExchangeId ? 1 : 0)
const [selectedExchangeType, setSelectedExchangeType] = useState('')
@@ -170,9 +173,6 @@ export function ExchangeConfigModal({
const [asterSigner, setAsterSigner] = useState('')
const [asterPrivateKey, setAsterPrivateKey] = useState('')
// Hyperliquid fields
const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('')
// Lighter fields
const [lighterWalletAddr, setLighterWalletAddr] = useState('')
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
@@ -219,7 +219,6 @@ export function ExchangeConfigModal({
setAsterUser(selectedExchange.asterUser || '')
setAsterSigner(selectedExchange.asterSigner || '')
setAsterPrivateKey('')
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '')
setLighterWalletAddr(selectedExchange.lighterWalletAddr || '')
setLighterApiKeyPrivateKey('')
setLighterApiKeyIndex(selectedExchange.lighterApiKeyIndex || 0)
@@ -278,12 +277,6 @@ export function ExchangeConfigModal({
setSecureInputTarget(null)
}
const maskSecret = (secret: string) => {
if (!secret || secret.length === 0) return ''
if (secret.length <= 8) return '*'.repeat(secret.length)
return secret.slice(0, 4) + '*'.repeat(Math.max(secret.length - 8, 4)) + secret.slice(-4)
}
const handleSelectExchange = (exchangeType: string) => {
setSelectedExchangeType(exchangeType)
setCurrentStep(1)
@@ -321,8 +314,8 @@ export function ExchangeConfigModal({
if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet)
} else if (currentExchangeType === 'hyperliquid') {
if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), '', '', testnet, hyperliquidWalletAddr.trim())
toast.error(language === 'zh' ? 'Hyperliquid 请使用钱包授权流程连接。' : 'Use the wallet authorization flow to connect Hyperliquid.')
return
} else if (currentExchangeType === 'aster') {
if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) return
await onSave(exchangeId, exchangeType, trimmedAccountName, '', '', '', testnet, undefined, asterUser.trim(), asterSigner.trim(), asterPrivateKey.trim())
@@ -688,32 +681,28 @@ export function ExchangeConfigModal({
</>
)}
{/* Hyperliquid Fields */}
{/* Hyperliquid Wallet Authorization */}
{currentExchangeType === 'hyperliquid' && (
<>
<div className="space-y-4">
<div className="p-4 rounded-xl" style={{ background: 'rgba(127, 231, 204, 0.1)', border: '1px solid rgba(127, 231, 204, 0.3)' }}>
<div className="flex items-start gap-2">
<span style={{ fontSize: '16px' }}>🔐</span>
<div>
<div className="text-sm font-semibold mb-1" style={{ color: '#7FE7CC' }}>{t('hyperliquidAgentWalletTitle', language)}</div>
<div className="text-xs" style={{ color: '#848E9C' }}>{t('hyperliquidAgentWalletDesc', language)}</div>
<div className="text-sm font-semibold mb-1" style={{ color: '#7FE7CC' }}>
{language === 'zh' ? 'Hyperliquid 必须走钱包授权' : 'Hyperliquid requires wallet authorization'}
</div>
<div className="text-xs leading-5" style={{ color: '#848E9C' }}>
{language === 'zh'
? '不再支持手动填写私钥/API Key。请用 MetaMask、Rabby、OKX、Coinbase Wallet 等 EVM 钱包完成连接、Agent 授权和 Builder fee 授权。'
: 'Manual private-key/API-key entry is disabled. Use MetaMask, Rabby, OKX, Coinbase Wallet or another EVM wallet to connect, authorize the agent, and approve the builder fee.'}
</div>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>{t('hyperliquidAgentPrivateKey', language)}</label>
<div className="flex gap-2">
<input type="text" value={maskSecret(apiKey)} readOnly placeholder={t('enterHyperliquidAgentPrivateKey', language)} className="flex-1 px-4 py-3 rounded-xl" style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} />
<button type="button" onClick={() => setSecureInputTarget('hyperliquid')} className="px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:scale-105" style={{ background: '#7FE7CC', color: '#000' }}>
{apiKey ? t('secureInputReenter', language) : t('secureInputButton', language)}
</button>
</div>
<div className="flex justify-start">
<HyperliquidWalletConnect language={language} isLoggedIn={Boolean(user)} variant="inline" />
</div>
<div className="space-y-2">
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>{t('hyperliquidMainWalletAddress', language)}</label>
<input type="text" value={hyperliquidWalletAddr} onChange={(e) => setHyperliquidWalletAddr(e.target.value)} placeholder={t('enterHyperliquidMainWalletAddress', language)} className="w-full px-4 py-3 rounded-xl" style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} required />
</div>
</>
</div>
)}
{/* Lighter Fields */}
@@ -758,18 +747,20 @@ export function ExchangeConfigModal({
{/* Buttons */}
<div className="flex gap-3 pt-4">
<button type="button" onClick={handleBack} className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5" style={{ background: '#2B3139', color: '#848E9C' }}>
{editingExchangeId ? t('cancel', language) : t('exchangeConfig.back', language)}
</button>
<button
type="submit"
disabled={isSaving || !accountName.trim()}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: '#F0B90B', color: '#000' }}
>
{isSaving ? t('saving', language) : (
<>{t('saveConfig', language)} <ArrowRight className="w-4 h-4" /></>
)}
{currentExchangeType === 'hyperliquid' ? t('closeGuide', language) : editingExchangeId ? t('cancel', language) : t('exchangeConfig.back', language)}
</button>
{currentExchangeType !== 'hyperliquid' && (
<button
type="submit"
disabled={isSaving || !accountName.trim()}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: '#F0B90B', color: '#000' }}
>
{isSaving ? t('saving', language) : (
<>{t('saveConfig', language)} <ArrowRight className="w-4 h-4" /></>
)}
</button>
)}
</div>
</form>
)}

55
web/src/lib/api/wallet.ts Normal file
View File

@@ -0,0 +1,55 @@
import { API_BASE, handleJSONResponse } from './helpers'
export interface GeneratedWallet {
address: string
private_key: string
}
export interface HyperliquidConnectConfig {
builderAddress: string
builderMaxFee: string
chain: string
signatureChainId: string
}
export interface HyperliquidSignature {
r: string
s: string
v: number
}
export interface HyperliquidAccountSummary {
address: string
accountValue: number
withdrawable: number
totalMarginUsed: number
unrealizedPnl: number
openPositions: number
updatedAt: number
}
export const walletApi = {
async generateWallet(): Promise<GeneratedWallet> {
const res = await fetch(`${API_BASE}/wallet/generate`, { method: 'POST' })
return handleJSONResponse<GeneratedWallet>(res)
},
async getHyperliquidConnectConfig(): Promise<HyperliquidConnectConfig> {
const res = await fetch(`${API_BASE}/hyperliquid/connect-config`)
return handleJSONResponse<HyperliquidConnectConfig>(res)
},
async getHyperliquidAccount(address: string): Promise<HyperliquidAccountSummary> {
const res = await fetch(`${API_BASE}/hyperliquid/account?address=${encodeURIComponent(address)}`)
return handleJSONResponse<HyperliquidAccountSummary>(res)
},
async submitHyperliquidApproval(action: Record<string, unknown>, nonce: number, signature: HyperliquidSignature) {
const res = await fetch(`${API_BASE}/hyperliquid/submit-exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, nonce, signature }),
})
return handleJSONResponse<{ success: boolean; response?: unknown }>(res)
},
}

View File

@@ -0,0 +1,144 @@
import { api } from './api'
import type { MarketSymbol } from './api/data'
import type { AIModel, Exchange, StrategyConfig } from '../types'
export interface QuickTradeResult {
traderId: string
traderName: string
strategyId: string
strategyName: string
symbol: string
display: string
reusedTrader: boolean
}
function compactSymbolName(symbol: string) {
return symbol.replace(/^xyz:/i, '').replace(/[^A-Za-z0-9_-]+/g, '').slice(0, 16) || 'SYMBOL'
}
function pickEnabledModel(models: AIModel[]) {
return models.find((m) => m.enabled)
}
function pickHyperliquidExchange(exchanges: Exchange[]) {
return exchanges.find((e) => {
const type = (e.exchange_type || e.id || '').toLowerCase()
return type === 'hyperliquid' && e.enabled && !!e.hyperliquidWalletAddr?.trim()
})
}
function buildSingleSymbolConfig(base: StrategyConfig, symbol: string, language: 'zh' | 'en'): StrategyConfig {
const staticCoinSource = {
source_type: 'static' as const,
static_coins: [symbol],
excluded_coins: [],
use_ai500: false,
use_oi_top: false,
use_oi_low: false,
use_hyper_all: false,
use_hyper_main: false,
}
const customPrompt =
language === 'zh'
? `只交易 Hyperliquid USDC 永续合约 ${symbol}。每次决策必须先检查账户余额、现有持仓、最新价格、趋势、成交量、资金费率和风险限制。没有明确优势时保持观望。单标的策略,不要切换到其他币种。`
: `Trade only the Hyperliquid USDC perpetual market ${symbol}. Before every decision, check balance, current positions, latest price, trend, volume, funding, and risk limits. Stay flat when there is no clear edge. Single-symbol strategy; do not switch to other symbols.`
return {
...base,
strategy_type: 'ai_trading',
language,
coin_source: staticCoinSource,
custom_prompt: customPrompt,
ai_config: {
...(base.ai_config || {}),
coin_source: staticCoinSource,
indicators: base.ai_config?.indicators || base.indicators!,
risk_control: base.ai_config?.risk_control || base.risk_control!,
prompt_sections: base.ai_config?.prompt_sections || base.prompt_sections,
custom_prompt: customPrompt,
},
}
}
export async function createHyperliquidQuickTrader(
symbolInput: MarketSymbol | { symbol: string; display?: string },
language: 'zh' | 'en'
): Promise<QuickTradeResult> {
const symbol = symbolInput.symbol
const display = symbolInput.display || symbol
const compact = compactSymbolName(display)
const traderName = `HL ${compact} Quick`.slice(0, 50)
const strategyName = `HL ${compact} Strategy`.slice(0, 50)
const [models, exchanges, traders, strategies] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getTraders(true),
api.getStrategies().catch(() => []),
])
const model = pickEnabledModel(models)
if (!model) {
throw new Error(language === 'zh' ? '没有可用 AI 模型,请先在 Config 里启用模型。' : 'No enabled AI model. Enable a model in Config first.')
}
const exchange = pickHyperliquidExchange(exchanges)
if (!exchange) {
throw new Error(language === 'zh' ? '没有可用 Hyperliquid 钱包,请先连接并保存 Hyperliquid。' : 'No usable Hyperliquid wallet. Connect and save Hyperliquid first.')
}
const existingTrader = traders.find((tr: any) =>
String(tr.name || '').toLowerCase() === traderName.toLowerCase() ||
(String(tr.exchange_id || '') === exchange.id && String(tr.trading_symbols || '').split(',').map((s) => s.trim()).includes(symbol))
)
if (existingTrader) {
const existing = existingTrader as any
return {
traderId: existing.trader_id || existing.id,
traderName: existing.trader_name || existing.name || traderName,
strategyId: existing.strategy_id || '',
strategyName,
symbol,
display,
reusedTrader: true,
}
}
let strategy = strategies.find((s: any) => String(s.name || '').toLowerCase() === strategyName.toLowerCase()) as any
if (!strategy?.id) {
const defaultConfig = await api.getDefaultStrategyConfig()
const config = buildSingleSymbolConfig(defaultConfig, symbol, language)
strategy = await api.createStrategy({
name: strategyName,
description:
language === 'zh'
? `Hyperliquid ${display} 单标的快速交易策略。`
: `Hyperliquid ${display} single-symbol quick trading strategy.`,
config,
} as any)
}
const trader = await api.createTrader({
name: traderName,
ai_model_id: model.id,
exchange_id: exchange.id,
strategy_id: strategy.id,
scan_interval_minutes: 5,
trading_symbols: symbol,
show_in_competition: false,
custom_prompt:
language === 'zh'
? `固定只交易 Hyperliquid ${symbol},不要扩展到其他标的。启动前再次检查余额、仓位和风险。`
: `Only trade Hyperliquid ${symbol}; do not expand to other symbols. Re-check balance, positions, and risk before starting.`,
})
return {
traderId: trader.trader_id || (trader as any).id,
traderName: trader.trader_name || (trader as any).name || traderName,
strategyId: strategy.id,
strategyName,
symbol,
display,
reusedTrader: false,
}
}