Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion api/exchange_account_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import (
"time"

"nofx/logger"
gmgnprovider "nofx/provider/gmgn"
"nofx/store"
"nofx/trader"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
gmgntrader "nofx/trader/gmgn"
hyperliquidtrader "nofx/trader/hyperliquid"
"nofx/trader/indodax"
"nofx/trader/kucoin"
Expand Down Expand Up @@ -166,6 +168,10 @@ func probeExchangeAccountState(exchangeCfg *store.Exchange, userID string) Excha
return state
}

if exchangeCfg.ExchangeType == "gmgn" {
return probeGMGNExchangeAccountState(exchangeCfg, state)
}

tempTrader, err := buildExchangeProbeTrader(exchangeCfg, userID)
if err != nil {
status, code, message := classifyExchangeProbeError(err)
Expand Down Expand Up @@ -223,6 +229,10 @@ func probeExchangeAccountState(exchangeCfg *store.Exchange, userID string) Excha
}

func buildExchangeProbeTrader(exchangeCfg *store.Exchange, userID string) (trader.Trader, error) {
return buildExchangeProbeTraderForWallet(exchangeCfg, userID, "", "")
}

func buildExchangeProbeTraderForWallet(exchangeCfg *store.Exchange, userID, chain, walletAddress string) (trader.Trader, error) {
switch exchangeCfg.ExchangeType {
case "binance":
return binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID), nil
Expand Down Expand Up @@ -258,11 +268,58 @@ func buildExchangeProbeTrader(exchangeCfg *store.Exchange, userID string) (trade
exchangeCfg.LighterAPIKeyIndex,
false,
)
case "gmgn":
if strings.TrimSpace(chain) == "" || strings.TrimSpace(walletAddress) == "" {
return nil, fmt.Errorf("gmgn requires chain and wallet address")
}
return gmgntrader.NewTrader(
string(exchangeCfg.GMGNAPIKey),
string(exchangeCfg.GMGNPrivateKey),
chain,
walletAddress,
)
default:
return nil, fmt.Errorf("unsupported exchange type: %s", exchangeCfg.ExchangeType)
}
}

func probeGMGNExchangeAccountState(exchangeCfg *store.Exchange, state ExchangeAccountState) ExchangeAccountState {
client, err := gmgnprovider.NewClient(string(exchangeCfg.GMGNAPIKey), string(exchangeCfg.GMGNPrivateKey))
if err != nil {
status, code, message := classifyExchangeProbeError(err)
state.Status = status
state.ErrorCode = code
state.ErrorMessage = message
return state
}

info, err := client.GetUserInfo()
if err != nil {
status, code, message := classifyExchangeProbeError(err)
state.Status = status
state.ErrorCode = code
state.ErrorMessage = message
return state
}

availableWallets := 0
for _, wallet := range info.Wallets {
if _, ok := gmgnprovider.GetChainConfig(wallet.Chain); ok {
availableWallets++
}
}
if availableWallets == 0 {
state.Status = exchangeAccountStatusUnavailable
state.ErrorCode = "NO_GMGN_WALLETS"
state.ErrorMessage = "GMGN credentials are valid but no supported wallets were found"
return state
}

state.Status = exchangeAccountStatusOK
state.DisplayBalance = fmt.Sprintf("%d wallet(s)", availableWallets)
return state
}

func extractExchangeTotalEquity(balanceInfo map[string]interface{}) (float64, bool) {
return extractFirstNumeric(balanceInfo,
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
Expand Down Expand Up @@ -311,7 +368,7 @@ func formatDisplayBalance(value float64, asset string) string {

func accountAssetForExchange(exchangeType string) string {
switch exchangeType {
case "hyperliquid", "aster", "lighter":
case "hyperliquid", "aster", "lighter", "gmgn":
return "USDC"
default:
return "USDT"
Expand Down Expand Up @@ -340,6 +397,10 @@ func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, cod
if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true
}
case "gmgn":
if exchangeCfg.GMGNAPIKey == "" || exchangeCfg.GMGNPrivateKey == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "GMGN API key and private key are required", true
}
default:
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
}
Expand Down
25 changes: 25 additions & 0 deletions api/gmgn_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package api

import (
"nofx/store"
"testing"
)

func TestValidateTraderExchangeSelectionRequiresGMGNChainAndWallet(t *testing.T) {
exchange := &store.Exchange{
ExchangeType: "gmgn",
Name: "GMGN",
AccountName: "Primary",
Enabled: true,
}

if msg, _, _ := validateTraderExchangeSelection(exchange, "", "0xabc"); msg == "" {
t.Fatal("expected missing chain validation error")
}
if msg, _, _ := validateTraderExchangeSelection(exchange, "sol", ""); msg == "" {
t.Fatal("expected missing wallet validation error")
}
if msg, _, _ := validateTraderExchangeSelection(exchange, "base", "0xabc"); msg != "" {
t.Fatalf("unexpected validation error: %s", msg)
}
}
73 changes: 71 additions & 2 deletions api/handler_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"

"nofx/config"
"nofx/crypto"
"nofx/logger"
gmgnprovider "nofx/provider/gmgn"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -53,6 +55,8 @@ type UpdateExchangeConfigRequest struct {
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
GMGNAPIKey string `json:"gmgn_api_key"`
GMGNPrivateKey string `json:"gmgn_private_key"`
} `json:"exchanges"`
}

Expand All @@ -74,6 +78,8 @@ type CreateExchangeRequest struct {
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
GMGNAPIKey string `json:"gmgn_api_key"`
GMGNPrivateKey string `json:"gmgn_private_key"`
}

// handleGetExchangeConfigs Get exchange configurations
Expand Down Expand Up @@ -185,7 +191,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
tradersToReload[t.ID] = true
}

err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex, exchangeData.GMGNAPIKey, exchangeData.GMGNPrivateKey)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return
Expand Down Expand Up @@ -265,7 +271,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
// Validate exchange type
validTypes := map[string]bool{
"binance": true, "bybit": true, "okx": true, "bitget": true,
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true,
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true, "gmgn": true,
}
if !validTypes[req.ExchangeType] {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
Expand All @@ -279,6 +285,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
req.GMGNAPIKey, req.GMGNPrivateKey,
)
if err != nil {
logger.Infof("❌ Failed to create exchange account: %v", err)
Expand Down Expand Up @@ -350,10 +357,72 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
{ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},
{ExchangeType: "gmgn", Name: "GMGN", Type: "dex"},
{ExchangeType: "alpaca", Name: "Alpaca (US Stocks)", Type: "stock"},
{ExchangeType: "forex", Name: "Forex (TwelveData)", Type: "forex"},
{ExchangeType: "metals", Name: "Metals (TwelveData)", Type: "metals"},
}

c.JSON(http.StatusOK, supportedExchanges)
}

func (s *Server) handleGetGMGNWallets(c *gin.Context) {
userID := c.GetString("user_id")
exchangeID := c.Param("id")

exchangeCfg, err := s.store.Exchange().GetByID(userID, exchangeID)
if err != nil {
SafeNotFound(c, "Exchange account")
return
}
if exchangeCfg.ExchangeType != "gmgn" {
SafeBadRequest(c, "Selected exchange is not GMGN")
return
}
if strings.TrimSpace(string(exchangeCfg.GMGNAPIKey)) == "" {
SafeBadRequest(c, "GMGN API Key is required")
return
}

client, err := gmgnprovider.NewClient(string(exchangeCfg.GMGNAPIKey), string(exchangeCfg.GMGNPrivateKey))
if err != nil {
SafeInternalError(c, "Failed to initialize GMGN client", err)
return
}

info, err := client.GetUserInfo()
if err != nil {
SafeInternalError(c, "Failed to fetch GMGN wallets", err)
return
}

wallets := make([]gin.H, 0, len(info.Wallets))
for _, wallet := range info.Wallets {
chain := gmgnprovider.NormalizeChain(wallet.Chain)
if _, ok := gmgnprovider.GetChainConfig(chain); !ok {
continue
}

usdcBalance := 0.0
nativeBalance := 0.0
if chainCfg, ok := gmgnprovider.GetChainConfig(chain); ok {
for _, balance := range wallet.Balances {
if strings.EqualFold(balance.TokenAddress, chainCfg.USDCAddress) || strings.EqualFold(balance.Symbol, chainCfg.USDCSymbol) {
usdcBalance += gmgnprovider.ParseFloatString(balance.Balance)
}
if strings.EqualFold(balance.Symbol, chainCfg.NativeTokenSymbol) {
nativeBalance += gmgnprovider.ParseFloatString(balance.Balance)
}
}
}

wallets = append(wallets, gin.H{
"chain": chain,
"wallet_address": wallet.Address,
"usdc_balance": usdcBalance,
"native_balance": nativeBalance,
})
}

c.JSON(http.StatusOK, gin.H{"wallets": wallets})
}
2 changes: 2 additions & 0 deletions api/handler_order.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
"trader_name": traderConfig.Name,
"ai_model": aiModelID,
"exchange_id": traderConfig.ExchangeID,
"chain": traderConfig.Chain,
"wallet_address": traderConfig.WalletAddress,
"strategy_id": traderConfig.StrategyID,
"initial_balance": traderConfig.InitialBalance,
"scan_interval_minutes": traderConfig.ScanIntervalMinutes,
Expand Down
Loading