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.
376 lines
13 KiB
Go
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 "", ""
|
|
}
|