mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 14:56:57 +08:00
feat: server-side launch preflight with structured checks and runtime AI wallet health
- 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.
This commit is contained in:
375
api/launch_preflight.go
Normal file
375
api/launch_preflight.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Launch readiness minimums. These are the single source of truth — the
|
||||
// frontend reads them from the preflight response instead of hardcoding.
|
||||
const (
|
||||
// MinAIFeeUSDC is the minimum Base USDC a claw402 fee wallet needs so the
|
||||
// trader can pay for its first AI/data calls.
|
||||
MinAIFeeUSDC = 1.0
|
||||
// MinTradingUSDC is the minimum available balance the exchange account
|
||||
// needs before the trader can place its first order.
|
||||
MinTradingUSDC = 12.0
|
||||
)
|
||||
|
||||
const (
|
||||
launchCheckStatusOK = "ok"
|
||||
launchCheckStatusFailed = "failed"
|
||||
launchCheckStatusWarning = "warning"
|
||||
launchCheckStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
// Check IDs — stable identifiers the frontend maps to guided-setup steps.
|
||||
const (
|
||||
launchCheckAIModel = "ai_model"
|
||||
launchCheckAIWallet = "ai_wallet"
|
||||
launchCheckAIWalletFunds = "ai_wallet_funds"
|
||||
launchCheckStrategy = "strategy"
|
||||
launchCheckExchangeConfig = "exchange_config"
|
||||
launchCheckExchangeAccount = "exchange_account"
|
||||
launchCheckExchangeFunds = "exchange_funds"
|
||||
)
|
||||
|
||||
// queryAIWalletBalance is swappable in tests to avoid live Base RPC calls.
|
||||
var queryAIWalletBalance = wallet.QueryUSDCBalanceCached
|
||||
|
||||
type LaunchCheck struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// Numeric context so the UI can render progress like "8.40 / 12 USDC".
|
||||
Required float64 `json:"required,omitempty"`
|
||||
Actual *float64 `json:"actual,omitempty"`
|
||||
Asset string `json:"asset,omitempty"`
|
||||
// Address is the funding address for balance checks, so the UI can offer
|
||||
// a deposit shortcut next to the failing item.
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
type LaunchPreflightResult struct {
|
||||
Ready bool `json:"ready"`
|
||||
Checks []LaunchCheck `json:"checks"`
|
||||
MinAIFeeUSDC float64 `json:"min_ai_fee_usdc"`
|
||||
MinTradingUSDC float64 `json:"min_trading_usdc"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
func (r LaunchPreflightResult) failedChecks() []LaunchCheck {
|
||||
var failed []LaunchCheck
|
||||
for _, check := range r.Checks {
|
||||
if check.Status == launchCheckStatusFailed {
|
||||
failed = append(failed, check)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// Summary joins the failing messages into one human-readable sentence.
|
||||
func (r LaunchPreflightResult) Summary() string {
|
||||
failed := r.failedChecks()
|
||||
if len(failed) == 0 {
|
||||
return ""
|
||||
}
|
||||
messages := make([]string, 0, len(failed))
|
||||
for _, check := range failed {
|
||||
if check.Message != "" {
|
||||
messages = append(messages, check.Message)
|
||||
}
|
||||
}
|
||||
return strings.Join(messages, " ")
|
||||
}
|
||||
|
||||
type launchPreflightRequest struct {
|
||||
AIModelID string `json:"ai_model_id"`
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
StrategyID string `json:"strategy_id"`
|
||||
}
|
||||
|
||||
// handleLaunchPreflight runs launch readiness checks for a model/exchange
|
||||
// pair before any trader is created or mutated. POST /api/launch/preflight
|
||||
func (s *Server) handleLaunchPreflight(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var req launchPreflightRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid preflight request")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.AIModelID) == "" || strings.TrimSpace(req.ExchangeID) == "" {
|
||||
SafeBadRequest(c, "ai_model_id and exchange_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
model = nil
|
||||
}
|
||||
exchange, err := s.store.Exchange().GetByID(userID, req.ExchangeID)
|
||||
if err != nil {
|
||||
exchange = nil
|
||||
}
|
||||
|
||||
var strategy *store.Strategy
|
||||
strategyRequired := strings.TrimSpace(req.StrategyID) != ""
|
||||
if strategyRequired {
|
||||
strategy, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
strategy = nil
|
||||
}
|
||||
}
|
||||
|
||||
result := s.runLaunchPreflight(userID, model, exchange, strategy, strategyRequired)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleTraderPreflight runs the same checks against an existing trader's
|
||||
// full configuration. GET /api/traders/:id/preflight
|
||||
func (s *Server) handleTraderPreflight(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil || fullCfg == nil || fullCfg.Trader == nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
result := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, true)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// runLaunchPreflight composes every launch readiness check. A check that
|
||||
// cannot be evaluated (RPC outage, probe timeout) reports "warning" instead
|
||||
// of "failed" so an infrastructure hiccup never hard-blocks a launch.
|
||||
func (s *Server) runLaunchPreflight(
|
||||
userID string,
|
||||
model *store.AIModel,
|
||||
exchange *store.Exchange,
|
||||
strategy *store.Strategy,
|
||||
strategyRequired bool,
|
||||
) LaunchPreflightResult {
|
||||
checks := []LaunchCheck{checkLaunchAIModel(model)}
|
||||
checks = append(checks, checkLaunchAIWallet(model)...)
|
||||
checks = append(checks, checkLaunchStrategy(strategy, strategyRequired))
|
||||
checks = append(checks, s.checkLaunchExchange(userID, exchange)...)
|
||||
|
||||
ready := true
|
||||
for _, check := range checks {
|
||||
if check.Status == launchCheckStatusFailed {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return LaunchPreflightResult{
|
||||
Ready: ready,
|
||||
Checks: checks,
|
||||
MinAIFeeUSDC: MinAIFeeUSDC,
|
||||
MinTradingUSDC: MinTradingUSDC,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func checkLaunchAIModel(model *store.AIModel) LaunchCheck {
|
||||
check := LaunchCheck{ID: launchCheckAIModel}
|
||||
|
||||
switch {
|
||||
case model == nil:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_NOT_FOUND"
|
||||
check.Message = "The selected AI model was not found. Configure an AI model first."
|
||||
case !model.Enabled:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_DISABLED"
|
||||
check.Message = fmt.Sprintf("AI model \"%s\" is disabled. Enable it first.", model.Name)
|
||||
case strings.TrimSpace(model.APIKey.String()) == "":
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_MISSING_CREDENTIALS"
|
||||
check.Message = fmt.Sprintf("AI model \"%s\" has no credential saved. Add the API key or wallet key first.", model.Name)
|
||||
default:
|
||||
check.Status = launchCheckStatusOK
|
||||
check.Message = model.Name
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// checkLaunchAIWallet validates the claw402 fee wallet (address + Base USDC
|
||||
// balance). Non-claw402 providers pay per API key, so both checks are skipped.
|
||||
func checkLaunchAIWallet(model *store.AIModel) []LaunchCheck {
|
||||
walletCheck := LaunchCheck{ID: launchCheckAIWallet}
|
||||
fundsCheck := LaunchCheck{ID: launchCheckAIWalletFunds, Asset: "USDC", Required: MinAIFeeUSDC}
|
||||
|
||||
if model == nil || model.Provider != "claw402" || strings.TrimSpace(model.APIKey.String()) == "" {
|
||||
walletCheck.Status = launchCheckStatusSkipped
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
address, err := walletAddressFromPrivateKey(model.APIKey.String())
|
||||
if err != nil {
|
||||
walletCheck.Status = launchCheckStatusFailed
|
||||
walletCheck.Code = "AI_WALLET_INVALID_KEY"
|
||||
walletCheck.Message = "The Claw402 wallet key is invalid. Recreate the Base USDC payment wallet."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
walletCheck.Status = launchCheckStatusOK
|
||||
walletCheck.Address = address
|
||||
fundsCheck.Address = address
|
||||
|
||||
balance, err := queryAIWalletBalance(address)
|
||||
if err != nil {
|
||||
fundsCheck.Status = launchCheckStatusWarning
|
||||
fundsCheck.Code = "AI_WALLET_BALANCE_UNKNOWN"
|
||||
fundsCheck.Message = "Could not verify the Base USDC balance right now. The trader will start, but AI calls fail if the wallet is empty."
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
fundsCheck.Actual = &balance
|
||||
if balance < MinAIFeeUSDC {
|
||||
fundsCheck.Status = launchCheckStatusFailed
|
||||
fundsCheck.Code = "AI_WALLET_INSUFFICIENT_FUNDS"
|
||||
fundsCheck.Message = fmt.Sprintf(
|
||||
"The Claw402 wallet holds %.2f USDC but needs at least %.0f USDC on Base to pay for AI and data calls.",
|
||||
balance, MinAIFeeUSDC,
|
||||
)
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusOK
|
||||
}
|
||||
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
func checkLaunchStrategy(strategy *store.Strategy, required bool) LaunchCheck {
|
||||
check := LaunchCheck{ID: launchCheckStrategy}
|
||||
|
||||
switch {
|
||||
case strategy != nil:
|
||||
check.Status = launchCheckStatusOK
|
||||
check.Message = strategy.Name
|
||||
case required:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "STRATEGY_NOT_FOUND"
|
||||
check.Message = "The selected strategy was not found. Pick or create a strategy first."
|
||||
default:
|
||||
check.Status = launchCheckStatusSkipped
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// checkLaunchExchange validates exchange configuration completeness and then
|
||||
// probes the live account (30s server cache) for status and balance.
|
||||
func (s *Server) checkLaunchExchange(userID string, exchange *store.Exchange) []LaunchCheck {
|
||||
configCheck := LaunchCheck{ID: launchCheckExchangeConfig}
|
||||
accountCheck := LaunchCheck{ID: launchCheckExchangeAccount}
|
||||
fundsCheck := LaunchCheck{ID: launchCheckExchangeFunds, Required: MinTradingUSDC}
|
||||
|
||||
if msg, code := describeExchangeConfigIssue(exchange); code != "" {
|
||||
configCheck.Status = launchCheckStatusFailed
|
||||
configCheck.Code = code
|
||||
configCheck.Message = msg
|
||||
accountCheck.Status = launchCheckStatusSkipped
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
configCheck.Status = launchCheckStatusOK
|
||||
configCheck.Message = exchangeDisplayName(exchange)
|
||||
fundsCheck.Asset = accountAssetForExchange(exchange.ExchangeType)
|
||||
|
||||
states, err := s.getExchangeAccountStates(userID)
|
||||
if err != nil {
|
||||
accountCheck.Status = launchCheckStatusWarning
|
||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
||||
accountCheck.Message = "Could not verify the exchange account right now."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
state, ok := states[exchange.ID]
|
||||
if !ok {
|
||||
accountCheck.Status = launchCheckStatusWarning
|
||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
||||
accountCheck.Message = "Could not verify the exchange account right now."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
if state.Status != exchangeAccountStatusOK {
|
||||
accountCheck.Status = launchCheckStatusFailed
|
||||
accountCheck.Code = state.ErrorCode
|
||||
accountCheck.Message = state.ErrorMessage
|
||||
if accountCheck.Message == "" {
|
||||
accountCheck.Message = fmt.Sprintf("Exchange account \"%s\" is not ready (%s).", exchangeDisplayName(exchange), state.Status)
|
||||
}
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
accountCheck.Status = launchCheckStatusOK
|
||||
accountCheck.Message = state.DisplayBalance
|
||||
|
||||
// Gate on the better of available balance and total equity: a bot with
|
||||
// capital deployed in open positions has low *available* margin but is
|
||||
// clearly funded — restarting it must not be blocked.
|
||||
funded := state.AvailableBalance
|
||||
if state.TotalEquity > funded {
|
||||
funded = state.TotalEquity
|
||||
}
|
||||
fundsCheck.Actual = &funded
|
||||
|
||||
if funded < MinTradingUSDC {
|
||||
message := fmt.Sprintf(
|
||||
"Exchange account \"%s\" holds %.2f %s but needs at least %.0f %s to place the first trade.",
|
||||
exchangeDisplayName(exchange), funded, fundsCheck.Asset, MinTradingUSDC, fundsCheck.Asset,
|
||||
)
|
||||
if exchange.Testnet {
|
||||
// Testnet balances are play money — warn instead of block.
|
||||
fundsCheck.Status = launchCheckStatusWarning
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusFailed
|
||||
}
|
||||
fundsCheck.Code = "EXCHANGE_INSUFFICIENT_FUNDS"
|
||||
fundsCheck.Message = message
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusOK
|
||||
}
|
||||
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
// describeExchangeConfigIssue mirrors the create-time exchange validation but
|
||||
// returns stable uppercase codes for the checklist UI.
|
||||
func describeExchangeConfigIssue(exchange *store.Exchange) (string, string) {
|
||||
if exchange == nil {
|
||||
return "The selected exchange account was not found. Connect an exchange first.", "EXCHANGE_NOT_FOUND"
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return fmt.Sprintf("Exchange account \"%s\" is disabled. Enable it first.", exchangeDisplayName(exchange)), "EXCHANGE_DISABLED"
|
||||
}
|
||||
if missing := missingExchangeFields(exchange); len(missing) > 0 {
|
||||
return fmt.Sprintf(
|
||||
"Exchange account \"%s\" is missing %s. Complete the connection first.",
|
||||
exchangeDisplayName(exchange), strings.Join(missing, ", "),
|
||||
), "EXCHANGE_MISSING_FIELDS"
|
||||
}
|
||||
if exchange.ExchangeType == "hyperliquid" && !exchange.HyperliquidBuilderApproved {
|
||||
return "Hyperliquid builder authorization is not complete. Reconnect the wallet and finish the authorization.", "HYPERLIQUID_BUILDER_NOT_APPROVED"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
Reference in New Issue
Block a user