mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
- POST /api/launch/preflight and GET /api/traders/:id/preflight run every launch prerequisite (AI model credential, claw402 wallet key + Base USDC balance, strategy, exchange config, live account state, funding minimums) and return a structured checklist with stable codes; minimums (1 USDC AI fee / 12 USDC trading) now live server-side and are exposed in the response. - POST /traders/:id/start enforces the preflight (400 with error_key trader.start.preflight_failed + full checklist; ?force=true to override). Funding gates use max(available, equity) so restarting a bot with deployed margin is not blocked; indeterminate probes (RPC outage) degrade to warnings instead of blocking. - Run loop now surfaces runtime health via GetStatus: safe_mode(+reason) and ai_wallet_status/balance (ok|low|empty|unknown), including typed detection of claw402 ErrInsufficientFunds instead of burying it in logs; safe-mode state is mutex-guarded for API readers. - Onboarding wallet endpoints switch to the cached, error-aware balance query and report balance_status so the UI can tell an RPC outage from an empty wallet.
294 lines
11 KiB
Go
294 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"nofx/crypto"
|
|
"nofx/store"
|
|
)
|
|
|
|
// Well-known throwaway development key (hardhat account #1) — never funded.
|
|
const testClaw402Key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
|
|
|
|
func withAIWalletBalance(t *testing.T, balance float64, err error) {
|
|
t.Helper()
|
|
original := queryAIWalletBalance
|
|
queryAIWalletBalance = func(string) (float64, error) {
|
|
return balance, err
|
|
}
|
|
t.Cleanup(func() {
|
|
queryAIWalletBalance = original
|
|
})
|
|
}
|
|
|
|
func findCheck(t *testing.T, checks []LaunchCheck, id string) LaunchCheck {
|
|
t.Helper()
|
|
for _, check := range checks {
|
|
if check.ID == id {
|
|
return check
|
|
}
|
|
}
|
|
t.Fatalf("check %q not found in %+v", id, checks)
|
|
return LaunchCheck{}
|
|
}
|
|
|
|
func TestCheckLaunchAIModel(t *testing.T) {
|
|
if got := checkLaunchAIModel(nil); got.Status != launchCheckStatusFailed || got.Code != "MODEL_NOT_FOUND" {
|
|
t.Fatalf("nil model: expected failed/MODEL_NOT_FOUND, got %+v", got)
|
|
}
|
|
|
|
disabled := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: false}
|
|
if got := checkLaunchAIModel(disabled); got.Code != "MODEL_DISABLED" {
|
|
t.Fatalf("disabled model: expected MODEL_DISABLED, got %+v", got)
|
|
}
|
|
|
|
noKey := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true}
|
|
if got := checkLaunchAIModel(noKey); got.Code != "MODEL_MISSING_CREDENTIALS" {
|
|
t.Fatalf("missing key: expected MODEL_MISSING_CREDENTIALS, got %+v", got)
|
|
}
|
|
|
|
ready := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
if got := checkLaunchAIModel(ready); got.Status != launchCheckStatusOK {
|
|
t.Fatalf("ready model: expected ok, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckLaunchAIWalletSkipsNonClaw402(t *testing.T) {
|
|
model := &store.AIModel{Name: "DeepSeek", Provider: "deepseek", Enabled: true, APIKey: crypto.EncryptedString("sk-test")}
|
|
checks := checkLaunchAIWallet(model)
|
|
if got := findCheck(t, checks, launchCheckAIWallet); got.Status != launchCheckStatusSkipped {
|
|
t.Fatalf("non-claw402 wallet check should be skipped, got %+v", got)
|
|
}
|
|
if got := findCheck(t, checks, launchCheckAIWalletFunds); got.Status != launchCheckStatusSkipped {
|
|
t.Fatalf("non-claw402 funds check should be skipped, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckLaunchAIWalletInvalidKey(t *testing.T) {
|
|
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString("not-a-key")}
|
|
checks := checkLaunchAIWallet(model)
|
|
got := findCheck(t, checks, launchCheckAIWallet)
|
|
if got.Status != launchCheckStatusFailed || got.Code != "AI_WALLET_INVALID_KEY" {
|
|
t.Fatalf("invalid key: expected failed/AI_WALLET_INVALID_KEY, got %+v", got)
|
|
}
|
|
if funds := findCheck(t, checks, launchCheckAIWalletFunds); funds.Status != launchCheckStatusSkipped {
|
|
t.Fatalf("funds check should be skipped when the key is invalid, got %+v", funds)
|
|
}
|
|
}
|
|
|
|
func TestCheckLaunchAIWalletInsufficientFunds(t *testing.T) {
|
|
withAIWalletBalance(t, 0.25, nil)
|
|
|
|
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
checks := checkLaunchAIWallet(model)
|
|
|
|
wallet := findCheck(t, checks, launchCheckAIWallet)
|
|
if wallet.Status != launchCheckStatusOK || wallet.Address == "" {
|
|
t.Fatalf("wallet check should pass with derived address, got %+v", wallet)
|
|
}
|
|
|
|
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
|
if funds.Status != launchCheckStatusFailed || funds.Code != "AI_WALLET_INSUFFICIENT_FUNDS" {
|
|
t.Fatalf("expected failed/AI_WALLET_INSUFFICIENT_FUNDS, got %+v", funds)
|
|
}
|
|
if funds.Actual == nil || *funds.Actual != 0.25 {
|
|
t.Fatalf("expected actual balance 0.25, got %+v", funds.Actual)
|
|
}
|
|
if funds.Required != MinAIFeeUSDC {
|
|
t.Fatalf("expected required %v, got %v", MinAIFeeUSDC, funds.Required)
|
|
}
|
|
if funds.Address == "" {
|
|
t.Fatalf("funds check should carry the deposit address")
|
|
}
|
|
}
|
|
|
|
func TestCheckLaunchAIWalletRPCOutageIsWarningNotFailure(t *testing.T) {
|
|
withAIWalletBalance(t, 0, errors.New("rpc unreachable"))
|
|
|
|
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
checks := checkLaunchAIWallet(model)
|
|
|
|
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
|
if funds.Status != launchCheckStatusWarning || funds.Code != "AI_WALLET_BALANCE_UNKNOWN" {
|
|
t.Fatalf("RPC outage must degrade to warning, got %+v", funds)
|
|
}
|
|
}
|
|
|
|
func TestCheckLaunchAIWalletFunded(t *testing.T) {
|
|
withAIWalletBalance(t, 25.5, nil)
|
|
|
|
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
checks := checkLaunchAIWallet(model)
|
|
|
|
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
|
if funds.Status != launchCheckStatusOK {
|
|
t.Fatalf("funded wallet should pass, got %+v", funds)
|
|
}
|
|
}
|
|
|
|
func TestDescribeExchangeConfigIssue(t *testing.T) {
|
|
if _, code := describeExchangeConfigIssue(nil); code != "EXCHANGE_NOT_FOUND" {
|
|
t.Fatalf("nil exchange: expected EXCHANGE_NOT_FOUND, got %s", code)
|
|
}
|
|
|
|
disabled := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: false}
|
|
if _, code := describeExchangeConfigIssue(disabled); code != "EXCHANGE_DISABLED" {
|
|
t.Fatalf("disabled: expected EXCHANGE_DISABLED, got %s", code)
|
|
}
|
|
|
|
missing := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: true}
|
|
if _, code := describeExchangeConfigIssue(missing); code != "EXCHANGE_MISSING_FIELDS" {
|
|
t.Fatalf("missing fields: expected EXCHANGE_MISSING_FIELDS, got %s", code)
|
|
}
|
|
|
|
unapproved := &store.Exchange{
|
|
ID: "ex",
|
|
ExchangeType: "hyperliquid",
|
|
Enabled: true,
|
|
APIKey: crypto.EncryptedString(testClaw402Key),
|
|
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
|
}
|
|
if _, code := describeExchangeConfigIssue(unapproved); code != "HYPERLIQUID_BUILDER_NOT_APPROVED" {
|
|
t.Fatalf("builder unapproved: expected HYPERLIQUID_BUILDER_NOT_APPROVED, got %s", code)
|
|
}
|
|
|
|
unapproved.HyperliquidBuilderApproved = true
|
|
if _, code := describeExchangeConfigIssue(unapproved); code != "" {
|
|
t.Fatalf("ready exchange: expected no issue, got %s", code)
|
|
}
|
|
}
|
|
|
|
func readyHyperliquidExchange() *store.Exchange {
|
|
return &store.Exchange{
|
|
ID: "ex-hl",
|
|
ExchangeType: "hyperliquid",
|
|
Enabled: true,
|
|
APIKey: crypto.EncryptedString(testClaw402Key),
|
|
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
|
HyperliquidBuilderApproved: true,
|
|
}
|
|
}
|
|
|
|
func preflightTestServer(t *testing.T, userID string, states map[string]ExchangeAccountState) *Server {
|
|
t.Helper()
|
|
server := &Server{exchangeAccountStateCache: NewExchangeAccountStateCache()}
|
|
if states != nil {
|
|
server.exchangeAccountStateCache.Set(userID, states)
|
|
}
|
|
return server
|
|
}
|
|
|
|
func TestCheckLaunchExchangeInsufficientFundsBlocks(t *testing.T) {
|
|
exchange := readyHyperliquidExchange()
|
|
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 5.5, TotalEquity: 5.5},
|
|
})
|
|
|
|
checks := server.checkLaunchExchange("user-1", exchange)
|
|
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
|
if funds.Status != launchCheckStatusFailed || funds.Code != "EXCHANGE_INSUFFICIENT_FUNDS" {
|
|
t.Fatalf("expected failed/EXCHANGE_INSUFFICIENT_FUNDS, got %+v", funds)
|
|
}
|
|
if funds.Actual == nil || *funds.Actual != 5.5 {
|
|
t.Fatalf("expected actual 5.5, got %+v", funds.Actual)
|
|
}
|
|
}
|
|
|
|
func TestCheckLaunchExchangeDeployedMarginPasses(t *testing.T) {
|
|
// A running bot with capital locked in positions: low available balance
|
|
// but healthy equity. Restart must not be blocked.
|
|
exchange := readyHyperliquidExchange()
|
|
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 3, TotalEquity: 100},
|
|
})
|
|
|
|
checks := server.checkLaunchExchange("user-1", exchange)
|
|
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
|
if funds.Status != launchCheckStatusOK {
|
|
t.Fatalf("deployed margin with healthy equity should pass, got %+v", funds)
|
|
}
|
|
if funds.Actual == nil || *funds.Actual != 100 {
|
|
t.Fatalf("expected actual 100 (equity), got %+v", funds.Actual)
|
|
}
|
|
}
|
|
|
|
func TestCheckLaunchExchangeTestnetLowFundsIsWarning(t *testing.T) {
|
|
exchange := readyHyperliquidExchange()
|
|
exchange.Testnet = true
|
|
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 0},
|
|
})
|
|
|
|
checks := server.checkLaunchExchange("user-1", exchange)
|
|
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
|
if funds.Status != launchCheckStatusWarning {
|
|
t.Fatalf("testnet low funds should warn, not block, got %+v", funds)
|
|
}
|
|
}
|
|
|
|
func TestCheckLaunchExchangeInvalidCredentials(t *testing.T) {
|
|
exchange := readyHyperliquidExchange()
|
|
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
exchange.ID: {
|
|
ExchangeID: exchange.ID,
|
|
Status: exchangeAccountStatusInvalidCredentials,
|
|
ErrorCode: "INVALID_CREDENTIALS",
|
|
ErrorMessage: "Exchange credentials are invalid",
|
|
},
|
|
})
|
|
|
|
checks := server.checkLaunchExchange("user-1", exchange)
|
|
account := findCheck(t, checks, launchCheckExchangeAccount)
|
|
if account.Status != launchCheckStatusFailed || account.Code != "INVALID_CREDENTIALS" {
|
|
t.Fatalf("expected failed/INVALID_CREDENTIALS, got %+v", account)
|
|
}
|
|
if funds := findCheck(t, checks, launchCheckExchangeFunds); funds.Status != launchCheckStatusSkipped {
|
|
t.Fatalf("funds check should be skipped when account probe fails, got %+v", funds)
|
|
}
|
|
}
|
|
|
|
func TestRunLaunchPreflightAggregatesReadiness(t *testing.T) {
|
|
withAIWalletBalance(t, 10, nil)
|
|
|
|
exchange := readyHyperliquidExchange()
|
|
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100, TotalEquity: 100},
|
|
})
|
|
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
strategy := &store.Strategy{ID: "strat-1", Name: "Autopilot"}
|
|
|
|
result := server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
|
if !result.Ready {
|
|
t.Fatalf("expected ready, got %+v", result)
|
|
}
|
|
if result.MinAIFeeUSDC != MinAIFeeUSDC || result.MinTradingUSDC != MinTradingUSDC {
|
|
t.Fatalf("minimums must be exposed in the response, got %+v", result)
|
|
}
|
|
|
|
// Break one prerequisite → not ready, and Summary explains it.
|
|
withAIWalletBalance(t, 0, nil)
|
|
result = server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
|
if result.Ready {
|
|
t.Fatalf("expected not ready with empty AI wallet, got %+v", result)
|
|
}
|
|
if result.Summary() == "" {
|
|
t.Fatalf("summary should describe the failing check")
|
|
}
|
|
}
|
|
|
|
func TestRunLaunchPreflightWarningsDoNotBlock(t *testing.T) {
|
|
withAIWalletBalance(t, 0, errors.New("rpc down"))
|
|
|
|
exchange := readyHyperliquidExchange()
|
|
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100},
|
|
})
|
|
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
|
|
result := server.runLaunchPreflight("user-1", model, exchange, nil, false)
|
|
if !result.Ready {
|
|
t.Fatalf("warnings must not block launch, got %+v", result)
|
|
}
|
|
}
|