feat(hyperliquid): Add Unified Account support for Spot as Perp collateral (#1387)

This PR adds support for Hyperliquid's Unified Account mode where Spot USDC
balance can be used as collateral for Perpetual trading.

Changes:
- Add HyperliquidUnifiedAcct field to Exchange config (default: true)
- Update HyperliquidTrader to support unified account mode
- When enabled, Spot USDC balance is added to available trading balance
- Update API request/response structs for unified account toggle
- Update trader config propagation from exchange config

This aligns with Hyperliquid's roadmap to make Unified Account the default.
This commit is contained in:
Hao Fu
2026-02-22 04:03:21 -05:00
committed by GitHub
parent 64935b9d47
commit 06d6080751
5 changed files with 58 additions and 28 deletions
+8 -2
View File
@@ -484,6 +484,7 @@ type UpdateExchangeConfigRequest struct {
Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
@@ -600,6 +601,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
string(exchangeCfg.APIKey), // private key
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
@@ -1169,6 +1171,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
@@ -1332,6 +1335,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
@@ -1906,7 +1910,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.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)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return
@@ -1940,6 +1944,7 @@ type CreateExchangeRequest struct {
Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
@@ -2014,7 +2019,8 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, req.Enabled,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
)
if err != nil {
+1
View File
@@ -700,6 +700,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
case "aster":
traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner
+13 -7
View File
@@ -29,6 +29,7 @@ type Exchange struct {
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
@@ -181,7 +182,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
// Create creates a new exchange account with UUID
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey,
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
id := uuid.New().String()
@@ -207,6 +209,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
Passphrase: crypto.EncryptedString(passphrase),
Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr,
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
AsterUser: asterUser,
AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
@@ -224,15 +227,17 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
// Update updates exchange configuration by UUID
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
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,
"enabled": enabled,
"testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
"aster_user": asterUser,
"aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex,
@@ -307,7 +312,8 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
// Check if this is an old-style ID (exchange type as ID)
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
hyperliquidWalletAddr, true, // Default to Unified Account mode
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return err
}
+5 -4
View File
@@ -63,9 +63,10 @@ type AutoTraderConfig struct {
KuCoinPassphrase string
// Hyperliquid configuration
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
// Aster configuration
AsterUser string // Aster main wallet address
@@ -260,7 +261,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
}
+31 -15
View File
@@ -21,12 +21,13 @@ import (
// HyperliquidTrader Hyperliquid trader
type HyperliquidTrader struct {
exchange *hyperliquid.Exchange
ctx context.Context
walletAddr string
meta *hyperliquid.Meta // Cache meta information (including precision)
metaMutex sync.RWMutex // Protect concurrent access to meta field
isCrossMargin bool // Whether to use cross margin mode
exchange *hyperliquid.Exchange
ctx context.Context
walletAddr string
meta *hyperliquid.Meta // Cache meta information (including precision)
metaMutex sync.RWMutex // Protect concurrent access to meta field
isCrossMargin bool // Whether to use cross margin mode
isUnifiedAccount bool // Whether to use Unified Account mode (Spot as collateral for Perps)
// xyz dex support (stocks, forex, commodities)
xyzMeta *xyzDexMeta
xyzMetaMutex sync.RWMutex
@@ -80,7 +81,8 @@ func isXyzDexAsset(symbol string) bool {
}
// NewHyperliquidTrader creates a Hyperliquid trader
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
// unifiedAccount: when true, Spot USDC balance is used as collateral for Perp trading
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, unifiedAccount bool) (*HyperliquidTrader, error) {
// Remove 0x prefix from private key (if present, case-insensitive)
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
@@ -175,14 +177,19 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
}
}
if unifiedAccount {
logger.Infof("✓ Unified Account mode enabled: Spot USDC will be used as collateral for Perp trading")
}
return &HyperliquidTrader{
exchange: exchange,
ctx: ctx,
walletAddr: walletAddr,
meta: meta,
isCrossMargin: true, // Use cross margin mode by default
privateKey: privateKey,
isTestnet: testnet,
exchange: exchange,
ctx: ctx,
walletAddr: walletAddr,
meta: meta,
isCrossMargin: true, // Use cross margin mode by default
isUnifiedAccount: unifiedAccount, // Unified Account: Spot as Perp collateral
privateKey: privateKey,
isTestnet: testnet,
}, nil
}
@@ -304,9 +311,18 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
// ✅ Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
if t.isUnifiedAccount && spotUSDCBalance > 0 {
// Add Spot balance to available balance for trading
availableBalance = availableBalance + spotUSDCBalance
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
spotUSDCBalance, availableBalance)
}
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
result["availableBalance"] = availableBalance // Available balance (Perpetuals only)
result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
result["spotBalance"] = spotUSDCBalance // Spot balance
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)