Files
nofx/store/exchange.go
T
tinkle-community 5c9e134e99 fix: ensure all timestamps use UTC timezone
- Add NowFunc to GORM config for UTC auto-generated timestamps
- Add .UTC() to all time.UnixMilli() calls in trader files
- Add .UTC() to all time.Now() calls in store and api files
- Fix TypeScript unused imports in frontend
2026-01-04 20:03:56 +08:00

331 lines
12 KiB
Go

package store
import (
"fmt"
"nofx/crypto"
"nofx/logger"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ExchangeStore exchange storage
type ExchangeStore struct {
db *gorm.DB
}
// Exchange exchange configuration
type Exchange struct {
ID string `gorm:"primaryKey" json:"id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
Enabled bool `gorm:"default:false" json:"enabled"`
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Exchange) TableName() string { return "exchanges" }
// NewExchangeStore creates a new ExchangeStore
func NewExchangeStore(db *gorm.DB) *ExchangeStore {
return &ExchangeStore{db: db}
}
func (s *ExchangeStore) initTables() error {
// For PostgreSQL with existing table, skip AutoMigrate
if s.db.Dialector.Name() == "postgres" {
var tableExists int64
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'exchanges'`).Scan(&tableExists)
if tableExists > 0 {
// Still run data migrations
s.migrateToMultiAccount()
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
return nil
}
}
if err := s.db.AutoMigrate(&Exchange{}); err != nil {
return err
}
// Run migration to multi-account if needed
if err := s.migrateToMultiAccount(); err != nil {
logger.Warnf("Multi-account migration warning: %v", err)
}
// Fix empty account_name for existing records
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
return nil
}
// migrateToMultiAccount migrates old schema (id=exchange_type) to new schema (id=UUID)
func (s *ExchangeStore) migrateToMultiAccount() error {
// Check if migration is needed by looking for old-style IDs (non-UUID)
var count int64
err := s.db.Model(&Exchange{}).
Where("exchange_type = '' AND id IN ?", []string{"binance", "bybit", "okx", "bitget", "hyperliquid", "aster", "lighter"}).
Count(&count).Error
if err != nil {
return err
}
if count == 0 {
return nil
}
logger.Infof("🔄 Migrating %d exchange records to multi-account schema...", count)
// Get all old records
var records []Exchange
err = s.db.Where("exchange_type = '' AND id IN ?", []string{"binance", "bybit", "okx", "bitget", "hyperliquid", "aster", "lighter"}).
Find(&records).Error
if err != nil {
return err
}
// Begin transaction
return s.db.Transaction(func(tx *gorm.DB) error {
for _, r := range records {
newID := uuid.New().String()
oldID := r.ID // This is the exchange type (e.g., "binance")
// Update traders table to use new UUID
if err := tx.Exec("UPDATE traders SET exchange_id = ? WHERE exchange_id = ? AND user_id = ?",
newID, oldID, r.UserID).Error; err != nil {
logger.Errorf("Failed to update traders for exchange %s: %v", oldID, err)
return err
}
// Update the exchange record
if err := tx.Model(&Exchange{}).
Where("id = ? AND user_id = ?", oldID, r.UserID).
Updates(map[string]interface{}{
"id": newID,
"exchange_type": oldID,
"account_name": "Default",
}).Error; err != nil {
logger.Errorf("Failed to migrate exchange %s: %v", oldID, err)
return err
}
logger.Infof("✅ Migrated exchange %s -> UUID %s for user %s", oldID, newID, r.UserID)
}
return nil
})
}
func (s *ExchangeStore) initDefaultData() error {
// No longer pre-populate exchanges - create on demand when user configures
return nil
}
// List gets user's exchange list
func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
var exchanges []*Exchange
err := s.db.Where("user_id = ?", userID).Order("exchange_type, account_name").Find(&exchanges).Error
if err != nil {
return nil, err
}
return exchanges, nil
}
// GetByID gets a specific exchange by UUID
func (s *ExchangeStore) GetByID(userID, id string) (*Exchange, error) {
var exchange Exchange
err := s.db.Where("id = ? AND user_id = ?", id, userID).First(&exchange).Error
if err != nil {
return nil, err
}
return &exchange, nil
}
// getExchangeNameAndType returns the display name and type for an exchange type
func getExchangeNameAndType(exchangeType string) (name string, typ string) {
switch exchangeType {
case "binance":
return "Binance Futures", "cex"
case "bybit":
return "Bybit Futures", "cex"
case "okx":
return "OKX Futures", "cex"
case "bitget":
return "Bitget Futures", "cex"
case "hyperliquid":
return "Hyperliquid", "dex"
case "aster":
return "Aster DEX", "dex"
case "lighter":
return "LIGHTER DEX", "dex"
default:
return exchangeType + " Exchange", "cex"
}
}
// Create creates a new exchange account with UUID
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
id := uuid.New().String()
name, typ := getExchangeNameAndType(exchangeType)
if accountName == "" {
accountName = "Default"
}
logger.Debugf("🔧 ExchangeStore.Create: userID=%s, exchangeType=%s, accountName=%s, id=%s",
userID, exchangeType, accountName, id)
exchange := &Exchange{
ID: id,
ExchangeType: exchangeType,
AccountName: accountName,
UserID: userID,
Name: name,
Type: typ,
Enabled: enabled,
APIKey: crypto.EncryptedString(apiKey),
SecretKey: crypto.EncryptedString(secretKey),
Passphrase: crypto.EncryptedString(passphrase),
Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr,
AsterUser: asterUser,
AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
LighterWalletAddr: lighterWalletAddr,
LighterPrivateKey: crypto.EncryptedString(lighterPrivateKey),
LighterAPIKeyPrivateKey: crypto.EncryptedString(lighterApiKeyPrivateKey),
LighterAPIKeyIndex: lighterApiKeyIndex,
}
if err := s.db.Create(exchange).Error; err != nil {
return "", err
}
return id, nil
}
// Update updates exchange configuration by UUID
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
updates := map[string]interface{}{
"enabled": enabled,
"testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
"aster_user": asterUser,
"aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now().UTC(),
}
// Only update encrypted fields if not empty
if apiKey != "" {
updates["api_key"] = crypto.EncryptedString(apiKey)
}
if secretKey != "" {
updates["secret_key"] = crypto.EncryptedString(secretKey)
}
if passphrase != "" {
updates["passphrase"] = crypto.EncryptedString(passphrase)
}
if asterPrivateKey != "" {
updates["aster_private_key"] = crypto.EncryptedString(asterPrivateKey)
}
if lighterPrivateKey != "" {
updates["lighter_private_key"] = crypto.EncryptedString(lighterPrivateKey)
}
if lighterApiKeyPrivateKey != "" {
updates["lighter_api_key_private_key"] = crypto.EncryptedString(lighterApiKeyPrivateKey)
}
result := s.db.Model(&Exchange{}).Where("id = ? AND user_id = ?", id, userID).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID)
}
return nil
}
// UpdateAccountName updates the account name for an exchange
func (s *ExchangeStore) UpdateAccountName(userID, id, accountName string) error {
result := s.db.Model(&Exchange{}).
Where("id = ? AND user_id = ?", id, userID).
Updates(map[string]interface{}{
"account_name": accountName,
"updated_at": time.Now().UTC(),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID)
}
return nil
}
// Delete deletes an exchange account
func (s *ExchangeStore) Delete(userID, id string) error {
result := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&Exchange{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID)
}
logger.Infof("🗑️ Deleted exchange: id=%s, userID=%s", id, userID)
return nil
}
// CreateLegacy creates exchange configuration (legacy API for backward compatibility)
// This method is deprecated, use Create instead
func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error {
// Check if this is an old-style ID (exchange type as ID)
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return err
}
// Otherwise assume it's already a UUID
exchange := &Exchange{
ID: id,
UserID: userID,
Name: name,
Type: typ,
Enabled: enabled,
APIKey: crypto.EncryptedString(apiKey),
SecretKey: crypto.EncryptedString(secretKey),
Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr,
AsterUser: asterUser,
AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
}
return s.db.Where("id = ?", id).FirstOrCreate(exchange).Error
}