mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
feat(trader): wire Hyperliquid wallet and quick trade flow
- Add wallet API endpoints and exchange storage fields for Hyperliquid - Normalize quick trade order paths, symbols, and builder fee coverage - Add frontend wallet connect and quick trade helpers
This commit is contained in:
@@ -26,84 +26,88 @@ type ExchangeConfig struct {
|
|||||||
|
|
||||||
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
|
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
|
||||||
type SafeExchangeConfig struct {
|
type SafeExchangeConfig struct {
|
||||||
ID string `json:"id"` // UUID
|
ID string `json:"id"` // UUID
|
||||||
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||||
AccountName string `json:"account_name"` // User-defined account name
|
AccountName string `json:"account_name"` // User-defined account name
|
||||||
Name string `json:"name"` // Display name
|
Name string `json:"name"` // Display name
|
||||||
Type string `json:"type"` // "cex" or "dex"
|
Type string `json:"type"` // "cex" or "dex"
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
HasAPIKey bool `json:"has_api_key"`
|
HasAPIKey bool `json:"has_api_key"`
|
||||||
HasSecretKey bool `json:"has_secret_key"`
|
HasSecretKey bool `json:"has_secret_key"`
|
||||||
HasPassphrase bool `json:"has_passphrase"`
|
HasPassphrase bool `json:"has_passphrase"`
|
||||||
Testnet bool `json:"testnet,omitempty"`
|
Testnet bool `json:"testnet,omitempty"`
|
||||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||||
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
|
||||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
||||||
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
||||||
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
||||||
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
|
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
||||||
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
|
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
|
||||||
|
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
||||||
return SafeExchangeConfig{
|
return SafeExchangeConfig{
|
||||||
ID: exchange.ID,
|
ID: exchange.ID,
|
||||||
ExchangeType: exchange.ExchangeType,
|
ExchangeType: exchange.ExchangeType,
|
||||||
AccountName: exchange.AccountName,
|
AccountName: exchange.AccountName,
|
||||||
Name: exchange.Name,
|
Name: exchange.Name,
|
||||||
Type: exchange.Type,
|
Type: exchange.Type,
|
||||||
Enabled: exchange.Enabled,
|
Enabled: exchange.Enabled,
|
||||||
HasAPIKey: exchange.APIKey != "",
|
HasAPIKey: exchange.APIKey != "",
|
||||||
HasSecretKey: exchange.SecretKey != "",
|
HasSecretKey: exchange.SecretKey != "",
|
||||||
HasPassphrase: exchange.Passphrase != "",
|
HasPassphrase: exchange.Passphrase != "",
|
||||||
Testnet: exchange.Testnet,
|
Testnet: exchange.Testnet,
|
||||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||||
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
|
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
|
||||||
AsterUser: exchange.AsterUser,
|
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
|
||||||
AsterSigner: exchange.AsterSigner,
|
AsterUser: exchange.AsterUser,
|
||||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
AsterSigner: exchange.AsterSigner,
|
||||||
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
|
LighterWalletAddr: exchange.LighterWalletAddr,
|
||||||
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
|
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
|
||||||
|
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateExchangeConfigRequest struct {
|
type UpdateExchangeConfigRequest struct {
|
||||||
Exchanges map[string]struct {
|
Exchanges map[string]struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
SecretKey string `json:"secret_key"`
|
SecretKey string `json:"secret_key"`
|
||||||
Passphrase string `json:"passphrase"` // OKX specific
|
Passphrase string `json:"passphrase"` // OKX specific
|
||||||
Testnet bool `json:"testnet"`
|
Testnet bool `json:"testnet"`
|
||||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||||
AsterUser string `json:"aster_user"`
|
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
||||||
AsterSigner string `json:"aster_signer"`
|
AsterUser string `json:"aster_user"`
|
||||||
AsterPrivateKey string `json:"aster_private_key"`
|
AsterSigner string `json:"aster_signer"`
|
||||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
AsterPrivateKey string `json:"aster_private_key"`
|
||||||
LighterPrivateKey string `json:"lighter_private_key"`
|
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
LighterPrivateKey string `json:"lighter_private_key"`
|
||||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||||
|
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||||
} `json:"exchanges"`
|
} `json:"exchanges"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateExchangeRequest request structure for creating a new exchange account
|
// CreateExchangeRequest request structure for creating a new exchange account
|
||||||
type CreateExchangeRequest struct {
|
type CreateExchangeRequest struct {
|
||||||
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||||
AccountName string `json:"account_name"` // User-defined account name
|
AccountName string `json:"account_name"` // User-defined account name
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
SecretKey string `json:"secret_key"`
|
SecretKey string `json:"secret_key"`
|
||||||
Passphrase string `json:"passphrase"`
|
Passphrase string `json:"passphrase"`
|
||||||
Testnet bool `json:"testnet"`
|
Testnet bool `json:"testnet"`
|
||||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||||
AsterUser string `json:"aster_user"`
|
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
|
||||||
AsterSigner string `json:"aster_signer"`
|
AsterUser string `json:"aster_user"`
|
||||||
AsterPrivateKey string `json:"aster_private_key"`
|
AsterSigner string `json:"aster_signer"`
|
||||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
AsterPrivateKey string `json:"aster_private_key"`
|
||||||
LighterPrivateKey string `json:"lighter_private_key"`
|
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
LighterPrivateKey string `json:"lighter_private_key"`
|
||||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||||
|
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetExchangeConfigs Get exchange configurations
|
// handleGetExchangeConfigs Get exchange configurations
|
||||||
@@ -241,6 +245,11 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
|||||||
if effectiveLighterWalletAddr == "" {
|
if effectiveLighterWalletAddr == "" {
|
||||||
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
|
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
|
||||||
}
|
}
|
||||||
|
effectiveHyperliquidBuilderApproved := existing.HyperliquidBuilderApproved
|
||||||
|
if exchangeData.HyperliquidBuilderApproved != nil {
|
||||||
|
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
|
||||||
|
}
|
||||||
|
|
||||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
if missing := store.MissingRequiredExchangeCredentialFields(
|
||||||
existing.ExchangeType,
|
existing.ExchangeType,
|
||||||
effectiveAPIKey,
|
effectiveAPIKey,
|
||||||
@@ -266,7 +275,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
|||||||
tradersToReload[t.ID] = true
|
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 {
|
if err != nil {
|
||||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||||
return
|
return
|
||||||
@@ -375,7 +384,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
|||||||
id, err := s.store.Exchange().Create(
|
id, err := s.store.Exchange().Create(
|
||||||
userID, req.ExchangeType, req.AccountName, true,
|
userID, req.ExchangeType, req.AccountName, true,
|
||||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
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.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
||||||
)
|
)
|
||||||
|
|||||||
308
api/handler_hyperliquid_wallet.go
Normal file
308
api/handler_hyperliquid_wallet.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,14 @@ func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, s
|
|||||||
return "", ""
|
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 {
|
func exchangeDisplayName(exchange *store.Exchange) string {
|
||||||
if exchange == nil {
|
if exchange == nil {
|
||||||
return "所选交易所账户"
|
return "所选交易所账户"
|
||||||
@@ -173,12 +181,12 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
|||||||
missing := missingExchangeFields(exchange)
|
missing := missingExchangeFields(exchange)
|
||||||
if len(missing) > 0 {
|
if len(missing) > 0 {
|
||||||
return formatTraderCreationError(
|
return formatTraderCreationError(
|
||||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||||
"exchange_name", exchangeDisplayName(exchange),
|
"exchange_name", exchangeDisplayName(exchange),
|
||||||
"missing_fields", strings.Join(missing, ", "),
|
"missing_fields", strings.Join(missing, ", "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch exchange.ExchangeType {
|
switch exchange.ExchangeType {
|
||||||
@@ -186,12 +194,12 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
|||||||
return "", "", nil
|
return "", "", nil
|
||||||
default:
|
default:
|
||||||
return formatTraderCreationError(
|
return formatTraderCreationError(
|
||||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||||
"exchange_name", exchangeDisplayName(exchange),
|
"exchange_name", exchangeDisplayName(exchange),
|
||||||
"exchange_type", exchange.ExchangeType,
|
"exchange_type", exchange.ExchangeType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,14 +335,16 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
return
|
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 != "" {
|
if req.TradingSymbols != "" {
|
||||||
symbols := strings.Split(req.TradingSymbols, ",")
|
symbols := strings.Split(req.TradingSymbols, ",")
|
||||||
for _, symbol := range symbols {
|
for _, symbol := range symbols {
|
||||||
symbol = strings.TrimSpace(symbol)
|
symbol = strings.TrimSpace(symbol)
|
||||||
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
|
if !isSupportedTraderSymbol(symbol) {
|
||||||
SafeBadRequestWithDetails(c, traderCreationRequestError(
|
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))
|
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -531,14 +541,14 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
|
|
||||||
if startupWarning == "" {
|
if startupWarning == "" {
|
||||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
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)
|
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if startupWarning == "" {
|
if startupWarning == "" {
|
||||||
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
|
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)
|
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)
|
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"trader_id": traderID,
|
"trader_id": traderID,
|
||||||
"trader_name": req.Name,
|
"trader_name": req.Name,
|
||||||
"ai_model": req.AIModelID,
|
"ai_model": req.AIModelID,
|
||||||
"is_running": false,
|
"is_running": false,
|
||||||
"startup_warning": startupWarning,
|
"startup_warning": startupWarning,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,6 +777,14 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
|||||||
traderName = fullCfg.Trader.Name
|
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
|
// Check if trader exists in memory and if it's running
|
||||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||||||
if existingTrader != nil {
|
if existingTrader != nil {
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ func (s *Server) setupRoutes() {
|
|||||||
// Wallet validation (no authentication required — used by frontend config form)
|
// Wallet validation (no authentication required — used by frontend config form)
|
||||||
api.POST("/wallet/validate", s.handleWalletValidate)
|
api.POST("/wallet/validate", s.handleWalletValidate)
|
||||||
api.POST("/wallet/generate", s.handleWalletGenerate)
|
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)
|
// Crypto related endpoints (no authentication required, not exposed to bot)
|
||||||
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
|
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
|
||||||
|
|||||||
120
cmd/e2e_builder_fee/main.go
Normal file
120
cmd/e2e_builder_fee/main.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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
|
// LoadUserTradersFromStore loads traders from store for a specific user to memory
|
||||||
func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string) error {
|
func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string) error {
|
||||||
tm.mu.Lock()
|
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)
|
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)
|
logger.Infof("✓ Trader %s loaded strategy config: %s", traderCfg.Name, strategy.Name)
|
||||||
|
ensureHyperliquidNativeStrategy(traderCfg.Name, exchangeCfg.ExchangeType, strategyConfig)
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name)
|
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)
|
// Build AutoTraderConfig (ai500APIURL/oiTopAPIURL obtained from strategy config, used in StrategyEngine)
|
||||||
traderConfig := trader.AutoTraderConfig{
|
traderConfig := trader.AutoTraderConfig{
|
||||||
ID: traderCfg.ID,
|
ID: traderCfg.ID,
|
||||||
|
|||||||
@@ -18,28 +18,29 @@ type ExchangeStore struct {
|
|||||||
|
|
||||||
// Exchange exchange configuration
|
// Exchange exchange configuration
|
||||||
type Exchange struct {
|
type Exchange struct {
|
||||||
ID string `gorm:"primaryKey" json:"id"`
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||||
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
|
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"`
|
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
|
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
|
||||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||||
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
||||||
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
|
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
|
||||||
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
|
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
|
||||||
Testnet bool `gorm:"default:false" json:"testnet"`
|
Testnet bool `gorm:"default:false" json:"testnet"`
|
||||||
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
|
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)
|
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"`
|
HyperliquidBuilderApproved bool `gorm:"column:hyperliquid_builder_approved;default:false" json:"hyperliquidBuilderApproved"`
|
||||||
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
|
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
|
||||||
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
|
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
|
||||||
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
|
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
|
||||||
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
|
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
|
||||||
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
|
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
|
||||||
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
|
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Exchange) TableName() string { return "exchanges" }
|
func (Exchange) TableName() string { return "exchanges" }
|
||||||
@@ -55,7 +56,10 @@ func (s *ExchangeStore) initTables() error {
|
|||||||
var tableExists int64
|
var tableExists int64
|
||||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'exchanges'`).Scan(&tableExists)
|
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'exchanges'`).Scan(&tableExists)
|
||||||
if tableExists > 0 {
|
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.migrateToMultiAccount()
|
||||||
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
|
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
|
||||||
if err := s.cleanupIncompleteExchangeConfigs(); err != nil {
|
if err := s.cleanupIncompleteExchangeConfigs(); err != nil {
|
||||||
@@ -70,6 +74,9 @@ func (s *ExchangeStore) initTables() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run migration to multi-account if needed
|
// 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 {
|
if err := s.migrateToMultiAccount(); err != nil {
|
||||||
logger.Warnf("Multi-account migration warning: %v", err)
|
logger.Warnf("Multi-account migration warning: %v", err)
|
||||||
}
|
}
|
||||||
@@ -83,6 +90,13 @@ func (s *ExchangeStore) initTables() error {
|
|||||||
return nil
|
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 {
|
func (s *ExchangeStore) cleanupIncompleteExchangeConfigs() error {
|
||||||
var exchanges []Exchange
|
var exchanges []Exchange
|
||||||
if err := s.db.Find(&exchanges).Error; err != nil {
|
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
|
// Create creates a new exchange account with UUID
|
||||||
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
|
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
|
||||||
apiKey, secretKey, passphrase string, testnet bool,
|
apiKey, secretKey, passphrase string, testnet bool,
|
||||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool, hyperliquidBuilderApproved bool,
|
||||||
asterUser, asterSigner, asterPrivateKey,
|
asterUser, asterSigner, asterPrivateKey,
|
||||||
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
|
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)
|
userID, exchangeType, accountName, id)
|
||||||
|
|
||||||
exchange := &Exchange{
|
exchange := &Exchange{
|
||||||
ID: id,
|
ID: id,
|
||||||
ExchangeType: exchangeType,
|
ExchangeType: exchangeType,
|
||||||
AccountName: accountName,
|
AccountName: accountName,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: typ,
|
Type: typ,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
APIKey: crypto.EncryptedString(apiKey),
|
APIKey: crypto.EncryptedString(apiKey),
|
||||||
SecretKey: crypto.EncryptedString(secretKey),
|
SecretKey: crypto.EncryptedString(secretKey),
|
||||||
Passphrase: crypto.EncryptedString(passphrase),
|
Passphrase: crypto.EncryptedString(passphrase),
|
||||||
Testnet: testnet,
|
Testnet: testnet,
|
||||||
HyperliquidWalletAddr: hyperliquidWalletAddr,
|
HyperliquidWalletAddr: hyperliquidWalletAddr,
|
||||||
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
|
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
|
||||||
AsterUser: asterUser,
|
HyperliquidBuilderApproved: exchangeType == "hyperliquid" && hyperliquidBuilderApproved,
|
||||||
AsterSigner: asterSigner,
|
AsterUser: asterUser,
|
||||||
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
|
AsterSigner: asterSigner,
|
||||||
LighterWalletAddr: lighterWalletAddr,
|
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
|
||||||
LighterPrivateKey: crypto.EncryptedString(lighterPrivateKey),
|
LighterWalletAddr: lighterWalletAddr,
|
||||||
LighterAPIKeyPrivateKey: crypto.EncryptedString(lighterApiKeyPrivateKey),
|
LighterPrivateKey: crypto.EncryptedString(lighterPrivateKey),
|
||||||
LighterAPIKeyIndex: lighterApiKeyIndex,
|
LighterAPIKeyPrivateKey: crypto.EncryptedString(lighterApiKeyPrivateKey),
|
||||||
|
LighterAPIKeyIndex: lighterApiKeyIndex,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(exchange).Error; err != nil {
|
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
|
// Update updates exchange configuration by UUID
|
||||||
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
|
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 {
|
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
||||||
|
|
||||||
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s", userID, id)
|
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s", userID, id)
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"testnet": testnet,
|
"testnet": testnet,
|
||||||
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
||||||
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
||||||
"aster_user": asterUser,
|
"hyperliquid_builder_approved": hyperliquidBuilderApproved,
|
||||||
"aster_signer": asterSigner,
|
"aster_user": asterUser,
|
||||||
"lighter_wallet_addr": lighterWalletAddr,
|
"aster_signer": asterSigner,
|
||||||
"lighter_api_key_index": lighterApiKeyIndex,
|
"lighter_wallet_addr": lighterWalletAddr,
|
||||||
"updated_at": time.Now().UTC(),
|
"lighter_api_key_index": lighterApiKeyIndex,
|
||||||
|
"updated_at": time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update encrypted fields if not empty
|
// 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)
|
// 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" {
|
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,
|
_, 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)
|
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"nofx/kernel"
|
"nofx/kernel"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
|
"nofx/market"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/wallet"
|
"nofx/wallet"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -207,6 +208,7 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
|
|
||||||
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
|
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
|
||||||
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
|
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
|
||||||
|
sortedDecisions = at.filterDecisionsToStrategyUniverse(sortedDecisions, ctx)
|
||||||
|
|
||||||
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
|
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
|
||||||
for i, d := range sortedDecisions {
|
for i, d := range sortedDecisions {
|
||||||
@@ -286,6 +288,52 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
return nil
|
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
|
// buildTradingContext builds trading context
|
||||||
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||||
// 1. Get account information
|
// 1. Get account information
|
||||||
|
|||||||
15
trader/hyperliquid/builder_fee_test.go
Normal file
15
trader/hyperliquid/builder_fee_test.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package hyperliquid
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDefaultBuilderIsHardcodedToApprovedFeeTier(t *testing.T) {
|
||||||
|
if defaultBuilder == nil {
|
||||||
|
t.Fatal("defaultBuilder is nil")
|
||||||
|
}
|
||||||
|
if got := defaultBuilder.Builder; got != "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d" {
|
||||||
|
t.Fatalf("defaultBuilder.Builder = %s, want hardcoded NOFX builder", got)
|
||||||
|
}
|
||||||
|
if got := defaultBuilder.Fee; got != 100 {
|
||||||
|
t.Fatalf("defaultBuilder.Fee = %d, want hardcoded 100 for 0.1%%", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"fmt"
|
"fmt"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
|
hlprovider "nofx/provider/hyperliquid"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -58,54 +59,25 @@ var xyzDexAssets = map[string]bool{
|
|||||||
"XYZ100": true,
|
"XYZ100": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultBuilder is the builder info for order routing
|
// defaultBuilder is the builder info for order routing.
|
||||||
// Set to nil to avoid requiring builder fee approval
|
// 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{
|
var defaultBuilder = &hyperliquid.BuilderInfo{
|
||||||
// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||||
// Fee: 10,
|
Fee: 100,
|
||||||
// }
|
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertSymbolToHyperliquid converts standard symbol to Hyperliquid format
|
// isXyzDexAsset checks if a symbol is an xyz dex asset.
|
||||||
// Example: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "silver" -> "xyz:SILVER"
|
// 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 {
|
func convertSymbolToHyperliquid(symbol string) string {
|
||||||
// Convert to uppercase for consistent handling
|
return hlprovider.FormatCoinForAPI(symbol)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// absFloat returns absolute value of float
|
// absFloat returns absolute value of float
|
||||||
|
|||||||
@@ -15,6 +15,28 @@ import (
|
|||||||
"github.com/sonirico/go-hyperliquid"
|
"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)
|
// 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) {
|
func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||||
// First cancel all pending orders for this coin
|
// First cancel all pending orders for this coin
|
||||||
@@ -71,7 +93,7 @@ func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage i
|
|||||||
ReduceOnly: false,
|
ReduceOnly: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
|
err = t.placeOrderWithBuilderFee(order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open long position: %w", err)
|
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,
|
ReduceOnly: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
|
err = t.placeOrderWithBuilderFee(order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open short position: %w", err)
|
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,
|
ReduceOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
|
err = t.placeOrderWithBuilderFee(order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to close long position: %w", err)
|
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,
|
ReduceOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
|
err = t.placeOrderWithBuilderFee(order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to close short position: %w", err)
|
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{
|
action := hyperliquid.OrderAction{
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Orders: []hyperliquid.OrderWire{orderWire},
|
Orders: []hyperliquid.OrderWire{orderWire},
|
||||||
Grouping: "na",
|
Grouping: "na",
|
||||||
Builder: nil,
|
Builder: defaultBuilder,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the action
|
// Sign the action
|
||||||
@@ -727,7 +750,7 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
|
|||||||
if len(result.Response.Data.Statuses) > 0 {
|
if len(result.Response.Data.Statuses) > 0 {
|
||||||
status := result.Response.Data.Statuses[0]
|
status := result.Response.Data.Statuses[0]
|
||||||
if status.Error != nil {
|
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 {
|
if status.Filled != nil {
|
||||||
logger.Infof("✅ xyz dex order filled: totalSz=%s avgPx=%s oid=%d",
|
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{
|
action := hyperliquid.OrderAction{
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Orders: []hyperliquid.OrderWire{orderWire},
|
Orders: []hyperliquid.OrderWire{orderWire},
|
||||||
Grouping: "na",
|
Grouping: "na",
|
||||||
Builder: nil,
|
Builder: defaultBuilder,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the action
|
// Sign the action
|
||||||
@@ -885,7 +909,7 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
|
|||||||
if len(result.Response.Data.Statuses) > 0 {
|
if len(result.Response.Data.Statuses) > 0 {
|
||||||
status := result.Response.Data.Statuses[0]
|
status := result.Response.Data.Statuses[0]
|
||||||
if status.Error != nil {
|
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 {
|
if status.Resting != nil {
|
||||||
logger.Infof("✅ xyz dex %s order placed: oid=%d", tpsl, status.Resting.Oid)
|
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,
|
ReduceOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
err := t.placeOrderWithBuilderFee(order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set stop loss: %w", err)
|
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,
|
ReduceOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
err := t.placeOrderWithBuilderFee(order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set take profit: %w", err)
|
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,
|
ReduceOnly: req.ReduceOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
err := t.placeOrderWithBuilderFee(order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
707
web/src/components/common/HyperliquidWalletConnect.tsx
Normal file
707
web/src/components/common/HyperliquidWalletConnect.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CreateTraderRequest,
|
CreateTraderRequest,
|
||||||
AIModel,
|
AIModel,
|
||||||
Exchange,
|
Exchange,
|
||||||
|
ExchangeAccountState,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
@@ -44,6 +45,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
const [editingTrader, setEditingTrader] = useState<any>(null)
|
const [editingTrader, setEditingTrader] = useState<any>(null)
|
||||||
const [allModels, setAllModels] = useState<AIModel[]>([])
|
const [allModels, setAllModels] = useState<AIModel[]>([])
|
||||||
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
|
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
|
||||||
|
const [exchangeAccountStates, setExchangeAccountStates] = useState<Record<string, ExchangeAccountState>>({})
|
||||||
|
const [isExchangeAccountStatesLoading, setIsExchangeAccountStatesLoading] = useState(false)
|
||||||
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
|
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
|
||||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
||||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
||||||
@@ -56,18 +59,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
setIsExchangeAccountStatesLoading(true)
|
||||||
modelConfigs,
|
try {
|
||||||
exchangeConfigs,
|
const [
|
||||||
models,
|
modelConfigs,
|
||||||
] = await Promise.all([
|
exchangeConfigs,
|
||||||
api.getModelConfigs(),
|
models,
|
||||||
api.getExchangeConfigs(),
|
accountStateResponse,
|
||||||
api.getSupportedModels(),
|
] = await Promise.all([
|
||||||
])
|
api.getModelConfigs(),
|
||||||
setAllModels(modelConfigs)
|
api.getExchangeConfigs(),
|
||||||
setAllExchanges(exchangeConfigs)
|
api.getSupportedModels(),
|
||||||
setSupportedModels(models)
|
api.getExchangeAccountState().catch(() => ({ states: {} })),
|
||||||
|
])
|
||||||
|
setAllModels(modelConfigs)
|
||||||
|
setAllExchanges(exchangeConfigs)
|
||||||
|
setExchangeAccountStates(accountStateResponse.states || {})
|
||||||
|
setSupportedModels(models)
|
||||||
|
} finally {
|
||||||
|
setIsExchangeAccountStatesLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle wallet address visibility for a trader
|
// Toggle wallet address visibility for a trader
|
||||||
@@ -96,6 +107,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Copy wallet address to clipboard
|
// Copy wallet address to clipboard
|
||||||
const handleCopyAddress = async (id: string, address: string) => {
|
const handleCopyAddress = async (id: string, address: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -691,6 +703,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
<ConfigStatusGrid
|
<ConfigStatusGrid
|
||||||
configuredModels={configuredModels}
|
configuredModels={configuredModels}
|
||||||
configuredExchanges={configuredExchanges}
|
configuredExchanges={configuredExchanges}
|
||||||
|
exchangeAccountStates={exchangeAccountStates}
|
||||||
|
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
|
||||||
visibleExchangeAddresses={visibleExchangeAddresses}
|
visibleExchangeAddresses={visibleExchangeAddresses}
|
||||||
copiedId={copiedId}
|
copiedId={copiedId}
|
||||||
language={language}
|
language={language}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import type { Exchange } from '../../types'
|
import type { Exchange } from '../../types'
|
||||||
import { t, type Language } from '../../i18n/translations'
|
import { t, type Language } from '../../i18n/translations'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import { HyperliquidWalletConnect } from '../common/HyperliquidWalletConnect'
|
||||||
import { getExchangeIcon } from '../common/ExchangeIcons'
|
import { getExchangeIcon } from '../common/ExchangeIcons'
|
||||||
import {
|
import {
|
||||||
TwoStageKeyModal,
|
TwoStageKeyModal,
|
||||||
@@ -151,6 +153,7 @@ export function ExchangeConfigModal({
|
|||||||
onClose,
|
onClose,
|
||||||
language,
|
language,
|
||||||
}: ExchangeConfigModalProps) {
|
}: ExchangeConfigModalProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
// Step: 0 = select exchange, 1 = configure
|
// Step: 0 = select exchange, 1 = configure
|
||||||
const [currentStep, setCurrentStep] = useState(editingExchangeId ? 1 : 0)
|
const [currentStep, setCurrentStep] = useState(editingExchangeId ? 1 : 0)
|
||||||
const [selectedExchangeType, setSelectedExchangeType] = useState('')
|
const [selectedExchangeType, setSelectedExchangeType] = useState('')
|
||||||
@@ -170,9 +173,6 @@ export function ExchangeConfigModal({
|
|||||||
const [asterSigner, setAsterSigner] = useState('')
|
const [asterSigner, setAsterSigner] = useState('')
|
||||||
const [asterPrivateKey, setAsterPrivateKey] = useState('')
|
const [asterPrivateKey, setAsterPrivateKey] = useState('')
|
||||||
|
|
||||||
// Hyperliquid fields
|
|
||||||
const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('')
|
|
||||||
|
|
||||||
// Lighter fields
|
// Lighter fields
|
||||||
const [lighterWalletAddr, setLighterWalletAddr] = useState('')
|
const [lighterWalletAddr, setLighterWalletAddr] = useState('')
|
||||||
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
|
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
|
||||||
@@ -219,7 +219,6 @@ export function ExchangeConfigModal({
|
|||||||
setAsterUser(selectedExchange.asterUser || '')
|
setAsterUser(selectedExchange.asterUser || '')
|
||||||
setAsterSigner(selectedExchange.asterSigner || '')
|
setAsterSigner(selectedExchange.asterSigner || '')
|
||||||
setAsterPrivateKey('')
|
setAsterPrivateKey('')
|
||||||
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '')
|
|
||||||
setLighterWalletAddr(selectedExchange.lighterWalletAddr || '')
|
setLighterWalletAddr(selectedExchange.lighterWalletAddr || '')
|
||||||
setLighterApiKeyPrivateKey('')
|
setLighterApiKeyPrivateKey('')
|
||||||
setLighterApiKeyIndex(selectedExchange.lighterApiKeyIndex || 0)
|
setLighterApiKeyIndex(selectedExchange.lighterApiKeyIndex || 0)
|
||||||
@@ -278,12 +277,6 @@ export function ExchangeConfigModal({
|
|||||||
setSecureInputTarget(null)
|
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) => {
|
const handleSelectExchange = (exchangeType: string) => {
|
||||||
setSelectedExchangeType(exchangeType)
|
setSelectedExchangeType(exchangeType)
|
||||||
setCurrentStep(1)
|
setCurrentStep(1)
|
||||||
@@ -321,8 +314,8 @@ export function ExchangeConfigModal({
|
|||||||
if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return
|
if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return
|
||||||
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet)
|
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet)
|
||||||
} else if (currentExchangeType === 'hyperliquid') {
|
} else if (currentExchangeType === 'hyperliquid') {
|
||||||
if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return
|
toast.error(language === 'zh' ? 'Hyperliquid 请使用钱包授权流程连接。' : 'Use the wallet authorization flow to connect Hyperliquid.')
|
||||||
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), '', '', testnet, hyperliquidWalletAddr.trim())
|
return
|
||||||
} else if (currentExchangeType === 'aster') {
|
} else if (currentExchangeType === 'aster') {
|
||||||
if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) return
|
if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) return
|
||||||
await onSave(exchangeId, exchangeType, trimmedAccountName, '', '', '', testnet, undefined, asterUser.trim(), asterSigner.trim(), asterPrivateKey.trim())
|
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' && (
|
{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="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">
|
<div className="flex items-start gap-2">
|
||||||
<span style={{ fontSize: '16px' }}>🔐</span>
|
<span style={{ fontSize: '16px' }}>🔐</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold mb-1" style={{ color: '#7FE7CC' }}>{t('hyperliquidAgentWalletTitle', language)}</div>
|
<div className="text-sm font-semibold mb-1" style={{ color: '#7FE7CC' }}>
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>{t('hyperliquidAgentWalletDesc', language)}</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="flex justify-start">
|
||||||
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>{t('hyperliquidAgentPrivateKey', language)}</label>
|
<HyperliquidWalletConnect language={language} isLoggedIn={Boolean(user)} variant="inline" />
|
||||||
<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>
|
</div>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lighter Fields */}
|
{/* Lighter Fields */}
|
||||||
@@ -758,18 +747,20 @@ export function ExchangeConfigModal({
|
|||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="flex gap-3 pt-4">
|
<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' }}>
|
<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)}
|
{currentExchangeType === 'hyperliquid' ? t('closeGuide', language) : 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" /></>
|
|
||||||
)}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
55
web/src/lib/api/wallet.ts
Normal file
55
web/src/lib/api/wallet.ts
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
144
web/src/lib/hyperliquidQuickTrade.ts
Normal file
144
web/src/lib/hyperliquidQuickTrade.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user