diff --git a/api/handler_exchange.go b/api/handler_exchange.go index 217aacaa..2d5795c0 100644 --- a/api/handler_exchange.go +++ b/api/handler_exchange.go @@ -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, ) diff --git a/api/handler_hyperliquid_wallet.go b/api/handler_hyperliquid_wallet.go new file mode 100644 index 00000000..10f783c9 --- /dev/null +++ b/api/handler_hyperliquid_wallet.go @@ -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") + } +} diff --git a/api/handler_trader.go b/api/handler_trader.go index 67d3157e..e363092b 100644 --- a/api/handler_trader.go +++ b/api/handler_trader.go @@ -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 { diff --git a/api/server.go b/api/server.go index 3c657175..0b594965 100644 --- a/api/server.go +++ b/api/server.go @@ -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) diff --git a/cmd/e2e_builder_fee/main.go b/cmd/e2e_builder_fee/main.go new file mode 100644 index 00000000..b0107670 --- /dev/null +++ b/cmd/e2e_builder_fee/main.go @@ -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") +} diff --git a/manager/trader_manager.go b/manager/trader_manager.go index dce65785..9da53b61 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -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, diff --git a/store/exchange.go b/store/exchange.go index 3819a376..60237277 100644 --- a/store/exchange.go +++ b/store/exchange.go @@ -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 } diff --git a/trader/auto_trader_loop.go b/trader/auto_trader_loop.go index c01b91f5..4e5c5e0b 100644 --- a/trader/auto_trader_loop.go +++ b/trader/auto_trader_loop.go @@ -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 diff --git a/trader/hyperliquid/builder_fee_test.go b/trader/hyperliquid/builder_fee_test.go new file mode 100644 index 00000000..355461c7 --- /dev/null +++ b/trader/hyperliquid/builder_fee_test.go @@ -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) + } +} diff --git a/trader/hyperliquid/trader.go b/trader/hyperliquid/trader.go index 2339a229..ff56b415 100644 --- a/trader/hyperliquid/trader.go +++ b/trader/hyperliquid/trader.go @@ -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 diff --git a/trader/hyperliquid/trader_orders.go b/trader/hyperliquid/trader_orders.go index 3543d655..7c8a82ce 100644 --- a/trader/hyperliquid/trader_orders.go +++ b/trader/hyperliquid/trader_orders.go @@ -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) } diff --git a/web/src/components/common/HyperliquidWalletConnect.tsx b/web/src/components/common/HyperliquidWalletConnect.tsx new file mode 100644 index 00000000..67ec9cde --- /dev/null +++ b/web/src/components/common/HyperliquidWalletConnect.tsx @@ -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 + 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() + 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) { + 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(() => getSavedState()) + const [account, setAccount] = useState(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, 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 ( +
+ {!inline && ( + + )} + + {(open || inline) && ( +
+
+
+
{text.title}
+
{text.noCustody}
+
{walletSupportLabel(language)}
+
+ {!inline && ( + + )} +
+ +
+
+ {steps.map((step, index) => ( +
+
+ {step.status === 'done' ? : index + 1} +
+ {step.label} +
+ ))} +
+ + {error && ( +
+ {error} +
+ )} + +
+ {state.mainWallet && ( +
+ Main + +
+ )} + {state.agentAddress && ( +
+ Agent + +
+ )} +
+ Network + Hyperliquid Mainnet +
+
+ + {state.mainWallet && ( +
+
+ {text.balance} + +
+ {balanceError ? ( +
{balanceError}
+ ) : ( +
+
+
{text.withdrawable}
+
{balanceLoading && !account ? 'Loading…' : `${formatUSDC(account?.withdrawable)} USDC`}
+
+
+
{text.equity}
+
{balanceLoading && !account ? 'Loading…' : `${formatUSDC(account?.accountValue)} USDC`}
+
+
+
{text.marginUsed}
+
{formatUSDC(account?.totalMarginUsed)} USDC
+
+
+
{text.unrealizedPnl}
+
= 0 ? 'text-emerald-300' : 'text-red-300'}`}>{formatSignedUSDC(account?.unrealizedPnl)} USDC
+
+
+ )} +
+ )} + +
+ {!state.mainWallet && } + {state.mainWallet && !agentReady && } + {agentReady && !agentApprovedReady && } + {agentApprovedReady && !builderReady && } + {builderReady && !state.savedExchangeId && } + {complete && ( + <> +
+ {text.done} +
+ + + )} +
+ +
+ + Open Hyperliquid + + +
+
+
+ )} +
+ ) +} + +function ActionButton({ busy, onClick, label }: { busy: boolean; onClick: () => void; label: string }) { + return ( + + ) +} diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index ca02046e..7044bcd0 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -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(null) const [allModels, setAllModels] = useState([]) const [allExchanges, setAllExchanges] = useState([]) + const [exchangeAccountStates, setExchangeAccountStates] = useState>({}) + const [isExchangeAccountStatesLoading, setIsExchangeAccountStatesLoading] = useState(false) const [supportedModels, setSupportedModels] = useState([]) const [visibleTraderAddresses, setVisibleTraderAddresses] = useState>(new Set()) const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState>(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) { { - 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' && ( - <> +
🔐
-
{t('hyperliquidAgentWalletTitle', language)}
-
{t('hyperliquidAgentWalletDesc', language)}
+
+ {language === 'zh' ? 'Hyperliquid 必须走钱包授权' : 'Hyperliquid requires wallet authorization'} +
+
+ {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.'} +
-
- -
- - -
+
+
-
- - 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 /> -
- +
)} {/* Lighter Fields */} @@ -758,18 +747,20 @@ export function ExchangeConfigModal({ {/* Buttons */}
- + {currentExchangeType !== 'hyperliquid' && ( + + )}
)} diff --git a/web/src/lib/api/wallet.ts b/web/src/lib/api/wallet.ts new file mode 100644 index 00000000..c9b4274e --- /dev/null +++ b/web/src/lib/api/wallet.ts @@ -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 { + const res = await fetch(`${API_BASE}/wallet/generate`, { method: 'POST' }) + return handleJSONResponse(res) + }, + + async getHyperliquidConnectConfig(): Promise { + const res = await fetch(`${API_BASE}/hyperliquid/connect-config`) + return handleJSONResponse(res) + }, + + async getHyperliquidAccount(address: string): Promise { + const res = await fetch(`${API_BASE}/hyperliquid/account?address=${encodeURIComponent(address)}`) + return handleJSONResponse(res) + }, + + async submitHyperliquidApproval(action: Record, 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) + }, +} diff --git a/web/src/lib/hyperliquidQuickTrade.ts b/web/src/lib/hyperliquidQuickTrade.ts new file mode 100644 index 00000000..b15ef8e0 --- /dev/null +++ b/web/src/lib/hyperliquidQuickTrade.ts @@ -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 { + 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, + } +}