Files
nofx/api/launch_preflight.go
tinkle-community b17d97effc 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.
2026-07-05 20:38:38 +09:00

376 lines
13 KiB
Go

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 "", ""
}