+ {/* runtime health banner — AI fee wallet dry / safe mode would otherwise
+ only be visible in server logs while the bot silently idles */}
+ {!on && status && (status.safe_mode || status.ai_wallet_status === 'empty' || status.ai_wallet_status === 'low') && (
+
+ )}
{/* config / identity strip — first row, flows directly under the global nav */}
{selectedTrader?.trader_name ?? 'NOFX'}
diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx
index 06ae9686..41f3a397 100644
--- a/web/src/components/trader/AITradersPage.tsx
+++ b/web/src/components/trader/AITradersPage.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import useSWR from 'swr'
import { api } from '../../lib/api'
+import { ApiError } from '../../lib/httpClient'
import type {
TraderInfo,
CreateTraderRequest,
@@ -305,7 +306,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
await mutateTraders()
} catch (error) {
- console.error('Failed to toggle trader:', error)
+ // Launch preflight rejections carry an actionable reason (e.g. "AI fee
+ // wallet is empty") — show it instead of a generic failure toast.
+ if (
+ error instanceof ApiError &&
+ error.errorKey === 'trader.start.preflight_failed'
+ ) {
+ toast.error(error.message)
+ return
+ }
toast.error(t('operationFailed', language))
}
}
@@ -662,6 +671,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
handleOpenClaw402Config()
} else if (setupTarget === 'hyperliquid') {
handleOpenHyperliquidConfig()
+ } else if (setupTarget === 'hyperliquid-funds') {
+ // Funding shortfall: bring the guided launch panel (with the trading
+ // balance step and live preflight polling) into view.
+ document
+ .getElementById('autopilot-launch-panel')
+ ?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ toast.info(
+ 'Deposit USDC to your Hyperliquid account, the balance check updates automatically.'
+ )
} else {
return
}
diff --git a/web/src/components/trader/AutopilotLaunchPanel.tsx b/web/src/components/trader/AutopilotLaunchPanel.tsx
index 640d45fa..5b76458e 100644
--- a/web/src/components/trader/AutopilotLaunchPanel.tsx
+++ b/web/src/components/trader/AutopilotLaunchPanel.tsx
@@ -18,6 +18,12 @@ import {
import { toast } from 'sonner'
import { api } from '../../lib/api'
import { buildDashboardPath, ROUTES } from '../../router/paths'
+import {
+ ensureClaw402Strategy,
+ launchAutopilot,
+} from '../../lib/launch/launchAutopilot'
+import { runLaunchPreflight } from '../../lib/launch/preflight'
+import type { LaunchPreflightResult } from '../../lib/launch/types'
import type {
AIModel,
CurrentBeginnerWalletResponse,
@@ -219,13 +225,6 @@ export function AutopilotLaunchPanel({
[models]
)
- const feeWalletAddress = claw402Model?.walletAddress || wallet?.address || ''
- const feeWalletBalance = parseNumber(
- claw402Model?.balanceUsdc || wallet?.balance_usdc
- )
- const feeReady =
- Boolean(feeWalletAddress) && feeWalletBalance >= MIN_AI_FEE_USDC
-
const hyperliquidExchange = useMemo(
() =>
exchanges.find(
@@ -238,17 +237,80 @@ export function AutopilotLaunchPanel({
[exchanges]
)
+ // Any hyperliquid account (even partially configured) is enough for the
+ // server preflight — it reports exactly which prerequisite is missing.
+ const preflightExchange = useMemo(
+ () =>
+ hyperliquidExchange ||
+ exchanges.find((exchange) => exchange.exchange_type === 'hyperliquid') ||
+ null,
+ [exchanges, hyperliquidExchange]
+ )
+
+ // Server-side preflight is the source of truth for balances: it queries the
+ // chain / exchange live (30s server cache) instead of trusting the balance
+ // snapshot cached in the model object. Poll while the panel is visible so
+ // deposits show up without a manual refresh.
+ const [preflight, setPreflight] = useState
(null)
+ const claw402ModelId = claw402Model?.id
+ const preflightExchangeId = preflightExchange?.id
+ useEffect(() => {
+ if (!isLoggedIn || !claw402ModelId || !preflightExchangeId) {
+ setPreflight(null)
+ return
+ }
+ let cancelled = false
+ const check = async () => {
+ try {
+ const result = await runLaunchPreflight({
+ ai_model_id: claw402ModelId,
+ exchange_id: preflightExchangeId,
+ })
+ if (!cancelled) setPreflight(result)
+ } catch {
+ // keep the last known result; client-derived fallbacks still render
+ }
+ }
+ void check()
+ const timer = setInterval(() => void check(), 20000)
+ return () => {
+ cancelled = true
+ clearInterval(timer)
+ }
+ }, [isLoggedIn, claw402ModelId, preflightExchangeId])
+
+ const preflightCheck = (id: string) =>
+ preflight?.checks.find((check) => check.id === id)
+
+ const feeWalletAddress =
+ claw402Model?.walletAddress ||
+ wallet?.address ||
+ preflightCheck('ai_wallet')?.address ||
+ ''
+ const feeFundsCheck = preflightCheck('ai_wallet_funds')
+ const feeWalletBalance =
+ feeFundsCheck?.actual ??
+ parseNumber(claw402Model?.balanceUsdc || wallet?.balance_usdc)
+ const minAIFeeUSDC = preflight?.min_ai_fee_usdc ?? MIN_AI_FEE_USDC
+ const feeReady = feeFundsCheck
+ ? feeFundsCheck.status !== 'failed' && Boolean(feeWalletAddress)
+ : Boolean(feeWalletAddress) && feeWalletBalance >= minAIFeeUSDC
+
const hyperliquidConnected = Boolean(hyperliquidExchange)
const exchangeState = hyperliquidExchange
? exchangeAccountStates[hyperliquidExchange.id]
: undefined
- const tradingBalance = parseNumber(
- exchangeState?.available_balance ?? exchangeState?.total_equity
- )
+ const accountCheck = preflightCheck('exchange_account')
+ const tradingFundsCheck = preflightCheck('exchange_funds')
+ const tradingBalance =
+ tradingFundsCheck?.actual ??
+ parseNumber(exchangeState?.available_balance ?? exchangeState?.total_equity)
+ const minTradingUSDC = preflight?.min_trading_usdc ?? MIN_TRADING_USDC
const tradingBalanceReady =
hyperliquidConnected &&
- exchangeState?.status === 'ok' &&
- tradingBalance >= MIN_TRADING_USDC
+ (accountCheck && tradingFundsCheck
+ ? accountCheck.status === 'ok' && tradingFundsCheck.status !== 'failed'
+ : exchangeState?.status === 'ok' && tradingBalance >= minTradingUSDC)
const autopilotTrader = useMemo(
() =>
@@ -286,90 +348,39 @@ export function AutopilotLaunchPanel({
}
}
- const ensureClaw402Strategy = async () => {
- const strategies = await api.getStrategies()
- const existing =
- strategies.find(
- (strategy) =>
- strategy.is_active &&
- strategy.config?.ai_config?.coin_source?.source_type ===
- 'vergex_signal'
- ) ||
- strategies.find((strategy) =>
- strategy.name.toLowerCase().includes('claw402')
- )
-
- if (existing) {
- if (!existing.is_active) {
- await api.activateStrategy(existing.id)
- }
- return existing.id
- }
-
- const config = await api.getDefaultStrategyConfig()
- const created = await api.createStrategy({
- name: 'NOFX Claw402 Auto Strategy',
- description:
- 'Single built-in strategy: Claw402 board, per-symbol details, raw candles, then execution.',
- config,
- })
- if (created?.id) {
- await api.activateStrategy(created.id)
- return created.id
- }
-
- const refreshed = await api.getStrategies()
- const fallback = refreshed.find((strategy) =>
- strategy.name.toLowerCase().includes('claw402')
- )
- if (!fallback) throw new Error('Failed to create Claw402 strategy')
- await api.activateStrategy(fallback.id)
- return fallback.id
- }
-
- const launchAutopilot = async () => {
+ const handleLaunch = async () => {
if (!claw402Model || !hyperliquidExchange) return
setLaunching(true)
try {
- // Re-fetch the live trader list before deciding. The `traders` prop can be
- // stale (open tab serving an old snapshot), which would make us create a
- // duplicate "NOFX Autopilot" — paying the ~35s first-create cost again and
- // orphaning the dashboard onto a deleted id (the "API Not Found" 404). Match
- // the same way the autopilotTrader memo does, falling back to it on failure.
- let trader = autopilotTrader
- try {
- const fresh = await api.getTraders(true)
- trader =
- fresh.find((t) => t.trader_name === 'NOFX Autopilot') ||
- fresh.find((t) =>
- (t.strategy_name || '').toLowerCase().includes('claw402')
- ) ||
- trader
- } catch {
- // network hiccup — keep the prop-derived trader rather than risk a dup
+ // Shared launch path (same as Strategy Studio): server preflight with
+ // fresh balances first, then strategy provisioning, then create/start.
+ const outcome = await launchAutopilot({
+ ensureStrategy: ensureClaw402Strategy,
+ scanIntervalMinutes: 5,
+ })
+
+ if (!outcome.ok) {
+ toast.error(outcome.message)
+ if (outcome.kind === 'preflight') {
+ setPreflight(outcome.preflight)
+ }
+ if (outcome.kind !== 'error') {
+ if (outcome.setupTarget === 'claw402') {
+ onOpenClaw402Config?.()
+ } else if (outcome.setupTarget === 'hyperliquid') {
+ onOpenHyperliquidConfig?.()
+ }
+ }
+ await refreshEverything()
+ return
}
- if (!trader) {
- const strategyId = await ensureClaw402Strategy()
- trader = await api.createTrader({
- name: 'NOFX Autopilot',
- ai_model_id: claw402Model.id,
- exchange_id: hyperliquidExchange.id,
- strategy_id: strategyId,
- scan_interval_minutes: 5,
- is_cross_margin: true,
- show_in_competition: true,
- btc_eth_leverage: 10,
- altcoin_leverage: 10,
- })
- }
- if (!trader.is_running) {
- await api.startTrader(trader.trader_id)
+
+ if (outcome.warning) {
+ toast.warning(outcome.warning)
}
await onRefresh()
toast.success('NOFX Autopilot is running')
- navigate(buildDashboardPath(trader.trader_id))
- } catch (err) {
- toast.error(err instanceof Error ? err.message : 'Launch failed')
+ navigate(buildDashboardPath(outcome.traderId))
} finally {
setLaunching(false)
}
@@ -388,7 +399,9 @@ export function AutopilotLaunchPanel({
'Pays Claw402.ai data and model calls with Base USDC. This is separate from trading collateral.',
status: feeReady ? 'ready' : 'action',
meta: feeWalletAddress
- ? `${shortAddress(feeWalletAddress)} · ${formatUSDC(feeWalletBalance)} USDC`
+ ? `${shortAddress(feeWalletAddress)} · ${formatUSDC(feeWalletBalance)} USDC${
+ feeReady ? '' : ` · needs ≥ ${minAIFeeUSDC} USDC`
+ }`
: 'Base USDC wallet required',
action: feeWalletAddress ? (
@@ -449,7 +462,9 @@ export function AutopilotLaunchPanel({
? 'action'
: 'blocked',
meta: hyperliquidConnected
- ? `${formatUSDC(tradingBalance)} USDC available`
+ ? `${formatUSDC(tradingBalance)} USDC available${
+ tradingBalanceReady ? '' : ` · needs ≥ ${minTradingUSDC} USDC`
+ }`
: 'Connect Hyperliquid first',
},
{
@@ -538,7 +553,7 @@ export function AutopilotLaunchPanel({
return (
void handleLaunch()}
disabled={launching || !allReady}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-nofx-gold px-4 py-3 text-sm font-bold text-white hover:bg-nofx-accent disabled:cursor-not-allowed disabled:opacity-60"
>
@@ -553,7 +568,10 @@ export function AutopilotLaunchPanel({
}
return (
-
+
diff --git a/web/src/lib/api/traders.ts b/web/src/lib/api/traders.ts
index 50dedbbe..978b108a 100644
--- a/web/src/lib/api/traders.ts
+++ b/web/src/lib/api/traders.ts
@@ -10,9 +10,10 @@ function throwApiError(
message: string,
errorKey?: string,
errorParams?: Record
,
- statusCode?: number
+ statusCode?: number,
+ errorData?: Record
): never {
- throw new ApiError(message, errorKey, errorParams, statusCode)
+ throw new ApiError(message, errorKey, errorParams, statusCode, errorData)
}
export const traderApi = {
@@ -61,7 +62,8 @@ export const traderApi = {
result.message || 'Failed to start trader',
result.errorKey,
result.errorParams,
- result.statusCode
+ result.statusCode,
+ result.errorData
)
}
},
diff --git a/web/src/lib/httpClient.ts b/web/src/lib/httpClient.ts
index 6607400a..417b4e74 100644
--- a/web/src/lib/httpClient.ts
+++ b/web/src/lib/httpClient.ts
@@ -22,24 +22,29 @@ export interface ApiResponse {
errorKey?: string
errorParams?: Record
statusCode?: number
+ /** Full error response body for endpoints that return structured details. */
+ errorData?: Record
}
export class ApiError extends Error {
errorKey?: string
errorParams?: Record
statusCode?: number
+ errorData?: Record
constructor(
message: string,
errorKey?: string,
errorParams?: Record,
- statusCode?: number
+ statusCode?: number,
+ errorData?: Record
) {
super(message)
this.name = 'ApiError'
this.errorKey = errorKey
this.errorParams = errorParams
this.statusCode = statusCode
+ this.errorData = errorData
}
}
@@ -254,6 +259,8 @@ export class HttpClient {
errorKey: errorData?.error_key,
errorParams: errorData?.error_params,
statusCode: error.response.status,
+ errorData:
+ errorData && typeof errorData === 'object' ? errorData : undefined,
}
}
diff --git a/web/src/lib/launch/launch.test.ts b/web/src/lib/launch/launch.test.ts
new file mode 100644
index 00000000..d9242690
--- /dev/null
+++ b/web/src/lib/launch/launch.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import {
+ describeLaunchFailures,
+ failedLaunchChecks,
+ primarySetupTarget,
+ setupTargetForCheck,
+} from './preflight'
+import { pickTradingExchange, pickTradingModel } from './resolve'
+import type { LaunchCheck, LaunchPreflightResult } from './types'
+import type { AIModel, Exchange } from '../../types'
+
+vi.mock('../api', () => ({ api: {} }))
+vi.mock('../api/helpers', () => ({
+ API_BASE: '',
+ httpClient: { get: vi.fn(), post: vi.fn() },
+}))
+
+function preflightResult(checks: LaunchCheck[]): LaunchPreflightResult {
+ return {
+ ready: checks.every((check) => check.status !== 'failed'),
+ checks,
+ min_ai_fee_usdc: 1,
+ min_trading_usdc: 12,
+ checked_at: new Date().toISOString(),
+ }
+}
+
+describe('setupTargetForCheck', () => {
+ it('routes AI wallet problems to claw402 setup', () => {
+ expect(setupTargetForCheck({ id: 'ai_model', status: 'failed' })).toBe(
+ 'claw402'
+ )
+ expect(setupTargetForCheck({ id: 'ai_wallet', status: 'failed' })).toBe(
+ 'claw402'
+ )
+ expect(
+ setupTargetForCheck({ id: 'ai_wallet_funds', status: 'failed' })
+ ).toBe('claw402')
+ })
+
+ it('routes exchange config/account problems to hyperliquid setup', () => {
+ expect(
+ setupTargetForCheck({ id: 'exchange_config', status: 'failed' })
+ ).toBe('hyperliquid')
+ expect(
+ setupTargetForCheck({ id: 'exchange_account', status: 'failed' })
+ ).toBe('hyperliquid')
+ })
+
+ it('routes funding shortfalls to the funds anchor', () => {
+ expect(
+ setupTargetForCheck({ id: 'exchange_funds', status: 'failed' })
+ ).toBe('hyperliquid-funds')
+ })
+
+ it('has no anchor for strategy problems', () => {
+ expect(setupTargetForCheck({ id: 'strategy', status: 'failed' })).toBeNull()
+ })
+})
+
+describe('primarySetupTarget', () => {
+ it('returns the anchor for the first failing check', () => {
+ const result = preflightResult([
+ { id: 'ai_model', status: 'ok' },
+ { id: 'ai_wallet_funds', status: 'failed', message: 'AI wallet empty.' },
+ { id: 'exchange_funds', status: 'failed', message: 'Low margin.' },
+ ])
+ expect(primarySetupTarget(result)).toBe('claw402')
+ })
+
+ it('returns null when everything passes', () => {
+ const result = preflightResult([
+ { id: 'ai_model', status: 'ok' },
+ { id: 'exchange_funds', status: 'warning' },
+ ])
+ expect(primarySetupTarget(result)).toBeNull()
+ })
+})
+
+describe('describeLaunchFailures / failedLaunchChecks', () => {
+ it('collects only failed checks and joins their messages', () => {
+ const result = preflightResult([
+ { id: 'ai_wallet_funds', status: 'failed', message: 'AI wallet empty.' },
+ { id: 'exchange_funds', status: 'warning', message: 'Testnet funds.' },
+ { id: 'exchange_account', status: 'failed', message: 'Bad key.' },
+ { id: 'strategy', status: 'skipped' },
+ ])
+ expect(failedLaunchChecks(result)).toHaveLength(2)
+ expect(describeLaunchFailures(result)).toBe('AI wallet empty. Bad key.')
+ })
+})
+
+describe('pickTradingModel', () => {
+ const base: Partial = { enabled: true }
+
+ it('prefers claw402 over other enabled models', () => {
+ const models = [
+ { ...base, id: 'openai', provider: 'openai', has_api_key: true },
+ { ...base, id: 'c402', provider: 'claw402', has_api_key: true },
+ ] as AIModel[]
+ expect(pickTradingModel(models)?.id).toBe('c402')
+ })
+
+ it('accepts a claw402 model with only a wallet address', () => {
+ const models = [
+ { ...base, id: 'c402', provider: 'claw402', walletAddress: '0xabc' },
+ ] as AIModel[]
+ expect(pickTradingModel(models)?.id).toBe('c402')
+ })
+
+ it('returns null when nothing usable exists', () => {
+ const models = [
+ { id: 'x', provider: 'openai', enabled: false, has_api_key: true },
+ { id: 'y', provider: 'deepseek', enabled: true },
+ ] as AIModel[]
+ expect(pickTradingModel(models)).toBeNull()
+ })
+})
+
+describe('pickTradingExchange', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('requires enabled + key + builder approval + wallet address', () => {
+ const ready = {
+ id: 'hl',
+ exchange_type: 'hyperliquid',
+ enabled: true,
+ has_api_key: true,
+ hyperliquidBuilderApproved: true,
+ hyperliquidWalletAddr: '0x1',
+ } as unknown as Exchange
+ expect(pickTradingExchange([ready])?.id).toBe('hl')
+
+ const unapproved = {
+ ...ready,
+ hyperliquidBuilderApproved: false,
+ } as unknown as Exchange
+ expect(pickTradingExchange([unapproved])).toBeNull()
+
+ const noAddr = {
+ ...ready,
+ hyperliquidWalletAddr: ' ',
+ } as unknown as Exchange
+ expect(pickTradingExchange([noAddr])).toBeNull()
+ })
+})
diff --git a/web/src/lib/launch/launchAutopilot.test.ts b/web/src/lib/launch/launchAutopilot.test.ts
new file mode 100644
index 00000000..40773dec
--- /dev/null
+++ b/web/src/lib/launch/launchAutopilot.test.ts
@@ -0,0 +1,172 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { launchAutopilot } from './launchAutopilot'
+import { ApiError } from '../httpClient'
+import type { LaunchPreflightResult } from './types'
+
+const mocks = vi.hoisted(() => ({
+ api: {
+ getTraders: vi.fn(),
+ createTrader: vi.fn(),
+ updateTrader: vi.fn(),
+ startTrader: vi.fn(),
+ },
+ runLaunchPreflight: vi.fn(),
+ resolveLaunchModel: vi.fn(),
+ resolveLaunchExchange: vi.fn(),
+}))
+
+vi.mock('../api', () => ({ api: mocks.api }))
+vi.mock('./preflight', async (importOriginal) => ({
+ ...(await importOriginal()),
+ runLaunchPreflight: mocks.runLaunchPreflight,
+}))
+vi.mock('./resolve', () => ({
+ resolveLaunchModel: mocks.resolveLaunchModel,
+ resolveLaunchExchange: mocks.resolveLaunchExchange,
+}))
+
+function readyPreflight(): LaunchPreflightResult {
+ return {
+ ready: true,
+ checks: [],
+ min_ai_fee_usdc: 1,
+ min_trading_usdc: 12,
+ checked_at: new Date().toISOString(),
+ }
+}
+
+function failedPreflight(): LaunchPreflightResult {
+ return {
+ ready: false,
+ checks: [
+ {
+ id: 'ai_wallet_funds',
+ status: 'failed',
+ code: 'AI_WALLET_INSUFFICIENT_FUNDS',
+ message: 'AI wallet needs 1 USDC.',
+ },
+ ],
+ min_ai_fee_usdc: 1,
+ min_trading_usdc: 12,
+ checked_at: new Date().toISOString(),
+ }
+}
+
+describe('launchAutopilot', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.resolveLaunchModel.mockResolvedValue({ id: 'model-1' })
+ mocks.resolveLaunchExchange.mockResolvedValue({
+ exchange: { id: 'ex-1' },
+ })
+ mocks.api.getTraders.mockResolvedValue([])
+ mocks.api.createTrader.mockResolvedValue({
+ trader_id: 't-1',
+ is_running: false,
+ })
+ mocks.api.startTrader.mockResolvedValue(undefined)
+ })
+
+ it('never touches the strategy when preflight fails', async () => {
+ mocks.runLaunchPreflight.mockResolvedValue(failedPreflight())
+ const ensureStrategy = vi.fn()
+
+ const outcome = await launchAutopilot({ ensureStrategy })
+
+ expect(ensureStrategy).not.toHaveBeenCalled()
+ expect(mocks.api.createTrader).not.toHaveBeenCalled()
+ expect(outcome.ok).toBe(false)
+ if (outcome.ok || outcome.kind !== 'preflight') {
+ throw new Error('expected a preflight failure outcome')
+ }
+ expect(outcome.setupTarget).toBe('claw402')
+ expect(outcome.message).toContain('AI wallet needs 1 USDC.')
+ })
+
+ it('creates and starts the trader after preflight passes', async () => {
+ mocks.runLaunchPreflight.mockResolvedValue(readyPreflight())
+ const ensureStrategy = vi.fn().mockResolvedValue('strat-1')
+
+ const outcome = await launchAutopilot({
+ ensureStrategy,
+ scanIntervalMinutes: 15,
+ })
+
+ expect(ensureStrategy).toHaveBeenCalledTimes(1)
+ expect(mocks.api.createTrader).toHaveBeenCalledWith(
+ expect.objectContaining({
+ strategy_id: 'strat-1',
+ scan_interval_minutes: 15,
+ ai_model_id: 'model-1',
+ exchange_id: 'ex-1',
+ })
+ )
+ expect(mocks.api.startTrader).toHaveBeenCalledWith('t-1')
+ expect(outcome).toEqual(
+ expect.objectContaining({ ok: true, traderId: 't-1' })
+ )
+ })
+
+ it('updates the existing autopilot instead of creating a duplicate', async () => {
+ mocks.runLaunchPreflight.mockResolvedValue(readyPreflight())
+ mocks.api.getTraders.mockResolvedValue([
+ { trader_id: 't-old', trader_name: 'NOFX Autopilot', is_running: false },
+ ])
+ mocks.api.updateTrader.mockResolvedValue({
+ trader_id: 't-old',
+ is_running: true,
+ })
+
+ const outcome = await launchAutopilot({
+ ensureStrategy: vi.fn().mockResolvedValue('strat-1'),
+ })
+
+ expect(mocks.api.updateTrader).toHaveBeenCalled()
+ expect(mocks.api.createTrader).not.toHaveBeenCalled()
+ expect(mocks.api.startTrader).not.toHaveBeenCalled()
+ expect(outcome).toEqual(
+ expect.objectContaining({ ok: true, traderId: 't-old' })
+ )
+ })
+
+ it('surfaces the server-side preflight result when start is rejected', async () => {
+ mocks.runLaunchPreflight.mockResolvedValue(readyPreflight())
+ mocks.api.startTrader.mockRejectedValue(
+ new ApiError(
+ 'preflight failed',
+ 'trader.start.preflight_failed',
+ undefined,
+ 400,
+ { preflight: failedPreflight() }
+ )
+ )
+
+ const outcome = await launchAutopilot({
+ ensureStrategy: vi.fn().mockResolvedValue('strat-1'),
+ })
+
+ expect(outcome.ok).toBe(false)
+ if (outcome.ok || outcome.kind !== 'preflight') {
+ throw new Error('expected a preflight failure outcome')
+ }
+ expect(outcome.setupTarget).toBe('claw402')
+ })
+
+ it('routes missing exchange setup to the hyperliquid anchor', async () => {
+ mocks.resolveLaunchExchange.mockResolvedValue({
+ exchange: null,
+ reason: 'No Hyperliquid account is connected.',
+ })
+
+ const outcome = await launchAutopilot({ ensureStrategy: vi.fn() })
+
+ expect(outcome).toEqual(
+ expect.objectContaining({
+ ok: false,
+ kind: 'setup',
+ setupTarget: 'hyperliquid',
+ })
+ )
+ expect(mocks.runLaunchPreflight).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/src/lib/launch/launchAutopilot.ts b/web/src/lib/launch/launchAutopilot.ts
new file mode 100644
index 00000000..2f522c29
--- /dev/null
+++ b/web/src/lib/launch/launchAutopilot.ts
@@ -0,0 +1,178 @@
+import { api } from '../api'
+import { ApiError } from '../httpClient'
+import {
+ describeLaunchFailures,
+ primarySetupTarget,
+ runLaunchPreflight,
+} from './preflight'
+import { resolveLaunchExchange, resolveLaunchModel } from './resolve'
+import type { LaunchOutcome } from './types'
+
+export const AUTOPILOT_TRADER_NAME = 'NOFX Autopilot'
+
+export interface LaunchAutopilotOptions {
+ /**
+ * Provides the strategy id to trade. Called only AFTER preflight passes so
+ * a failed launch never mutates or activates a strategy as a side effect.
+ */
+ ensureStrategy: () => Promise
+ scanIntervalMinutes?: number
+}
+
+/**
+ * The single Autopilot launch path shared by Strategy Studio and the guided
+ * launch panel. Order matters: resolve → preflight (server-side, fresh
+ * balances) → strategy → create/update trader → start. No side effects happen
+ * before preflight passes.
+ */
+export async function launchAutopilot(
+ options: LaunchAutopilotOptions
+): Promise {
+ const { ensureStrategy, scanIntervalMinutes = 5 } = options
+
+ try {
+ const model = await resolveLaunchModel()
+ if (!model) {
+ return {
+ ok: false,
+ kind: 'setup',
+ message:
+ 'No enabled AI model is ready. Create or fund the Claw402 wallet first.',
+ setupTarget: 'claw402',
+ }
+ }
+
+ const exchangeResult = await resolveLaunchExchange()
+ if (!exchangeResult.exchange) {
+ return {
+ ok: false,
+ kind: 'setup',
+ message: exchangeResult.reason,
+ setupTarget: 'hyperliquid',
+ }
+ }
+ const exchange = exchangeResult.exchange
+
+ const preflight = await runLaunchPreflight({
+ ai_model_id: model.id,
+ exchange_id: exchange.id,
+ })
+ if (!preflight.ready) {
+ return {
+ ok: false,
+ kind: 'preflight',
+ message:
+ describeLaunchFailures(preflight) ||
+ 'Launch prerequisites are not ready yet.',
+ preflight,
+ setupTarget: primarySetupTarget(preflight),
+ }
+ }
+
+ const strategyId = await ensureStrategy()
+
+ const traderRequest = {
+ name: AUTOPILOT_TRADER_NAME,
+ ai_model_id: model.id,
+ exchange_id: exchange.id,
+ strategy_id: strategyId,
+ scan_interval_minutes: scanIntervalMinutes,
+ is_cross_margin: true,
+ show_in_competition: true,
+ btc_eth_leverage: 10,
+ altcoin_leverage: 10,
+ }
+
+ // Re-fetch the live trader list before deciding create vs update. Stale
+ // props/snapshots would create a duplicate "NOFX Autopilot" — paying the
+ // slow first-create cost again and orphaning dashboards onto a deleted id.
+ const existingTraders = await api.getTraders(true)
+ const existing =
+ existingTraders.find(
+ (trader) => trader.trader_name === AUTOPILOT_TRADER_NAME
+ ) ||
+ existingTraders.find((trader) =>
+ (trader.strategy_name || '').toLowerCase().includes('claw402')
+ ) ||
+ null
+
+ const autopilot = existing
+ ? await api.updateTrader(existing.trader_id, traderRequest)
+ : await api.createTrader(traderRequest)
+
+ if (!autopilot.is_running) {
+ await api.startTrader(autopilot.trader_id)
+ }
+
+ return {
+ ok: true,
+ traderId: autopilot.trader_id,
+ warning: autopilot.startup_warning,
+ }
+ } catch (err) {
+ // The server re-runs preflight on start; surface its structured result if
+ // readiness changed between our check and the start call.
+ if (err instanceof ApiError && err.errorKey === 'trader.start.preflight_failed') {
+ const preflight = err.errorData?.preflight
+ if (preflight) {
+ return {
+ ok: false,
+ kind: 'preflight',
+ message: describeLaunchFailures(preflight) || err.message,
+ preflight,
+ setupTarget: primarySetupTarget(preflight),
+ }
+ }
+ }
+ return {
+ ok: false,
+ kind: 'error',
+ message:
+ err instanceof Error ? err.message : 'Failed to launch NOFX Autopilot',
+ }
+ }
+}
+
+/**
+ * Default strategy provisioning for the guided panel: reuse the active
+ * Claw402 strategy, otherwise create and activate it.
+ */
+export async function ensureClaw402Strategy(): Promise {
+ const strategies = await api.getStrategies()
+ const existing =
+ strategies.find(
+ (strategy) =>
+ strategy.is_active &&
+ strategy.config?.ai_config?.coin_source?.source_type === 'vergex_signal'
+ ) ||
+ strategies.find((strategy) =>
+ strategy.name.toLowerCase().includes('claw402')
+ )
+
+ if (existing) {
+ if (!existing.is_active) {
+ await api.activateStrategy(existing.id)
+ }
+ return existing.id
+ }
+
+ const config = await api.getDefaultStrategyConfig()
+ const created = await api.createStrategy({
+ name: 'NOFX Claw402 Auto Strategy',
+ description:
+ 'Single built-in strategy: Claw402 board, per-symbol details, raw candles, then execution.',
+ config,
+ })
+ if (created?.id) {
+ await api.activateStrategy(created.id)
+ return created.id
+ }
+
+ const refreshed = await api.getStrategies()
+ const fallback = refreshed.find((strategy) =>
+ strategy.name.toLowerCase().includes('claw402')
+ )
+ if (!fallback) throw new Error('Failed to create Claw402 strategy')
+ await api.activateStrategy(fallback.id)
+ return fallback.id
+}
diff --git a/web/src/lib/launch/preflight.ts b/web/src/lib/launch/preflight.ts
new file mode 100644
index 00000000..72ce8966
--- /dev/null
+++ b/web/src/lib/launch/preflight.ts
@@ -0,0 +1,90 @@
+import { API_BASE, httpClient } from '../api/helpers'
+import type {
+ LaunchCheck,
+ LaunchPreflightResult,
+ SetupTarget,
+} from './types'
+
+export interface LaunchPreflightRequest {
+ ai_model_id: string
+ exchange_id: string
+ strategy_id?: string
+}
+
+/**
+ * Server-side launch readiness checks. This is the single source of truth for
+ * balances and minimums — never gate the UI on cached client-side values.
+ */
+export async function runLaunchPreflight(
+ request: LaunchPreflightRequest
+): Promise {
+ const result = await httpClient.post(
+ `${API_BASE}/launch/preflight`,
+ request
+ )
+ if (!result.success || !result.data) {
+ throw new Error(result.message || 'Failed to run launch preflight')
+ }
+ return result.data
+}
+
+export async function getTraderPreflight(
+ traderId: string
+): Promise {
+ const result = await httpClient.get(
+ `${API_BASE}/traders/${traderId}/preflight`
+ )
+ if (!result.success || !result.data) {
+ throw new Error(result.message || 'Failed to run launch preflight')
+ }
+ return result.data
+}
+
+export function failedLaunchChecks(
+ result: LaunchPreflightResult
+): LaunchCheck[] {
+ return result.checks.filter((check) => check.status === 'failed')
+}
+
+export function launchWarnings(result: LaunchPreflightResult): LaunchCheck[] {
+ return result.checks.filter((check) => check.status === 'warning')
+}
+
+/** Human-readable one-liner combining every failing check. */
+export function describeLaunchFailures(result: LaunchPreflightResult): string {
+ return failedLaunchChecks(result)
+ .map((check) => check.message)
+ .filter(Boolean)
+ .join(' ')
+}
+
+/**
+ * Maps a failing check to the guided-setup anchor that fixes it. The traders
+ * page opens the matching modal/section from the `?setup=` query param.
+ */
+export function setupTargetForCheck(check: LaunchCheck): SetupTarget | null {
+ switch (check.id) {
+ case 'ai_model':
+ case 'ai_wallet':
+ case 'ai_wallet_funds':
+ return 'claw402'
+ case 'exchange_config':
+ case 'exchange_account':
+ return 'hyperliquid'
+ case 'exchange_funds':
+ return 'hyperliquid-funds'
+ default:
+ return null
+ }
+}
+
+/** The setup anchor for the first (highest-priority) failing check. */
+export function primarySetupTarget(
+ result: LaunchPreflightResult
+): SetupTarget | null {
+ for (const check of failedLaunchChecks(result)) {
+ const target = setupTargetForCheck(check)
+ if (target) return target
+ }
+ return null
+}
diff --git a/web/src/lib/launch/resolve.ts b/web/src/lib/launch/resolve.ts
new file mode 100644
index 00000000..c621d537
--- /dev/null
+++ b/web/src/lib/launch/resolve.ts
@@ -0,0 +1,136 @@
+import { api } from '../api'
+import type { AIModel, Exchange } from '../../types'
+
+export function modelHasCredential(model: AIModel) {
+ return Boolean(
+ model.has_api_key ||
+ model.apiKey ||
+ (model.provider === 'claw402' && model.walletAddress)
+ )
+}
+
+export function exchangeHasKey(exchange: Exchange) {
+ return Boolean(exchange.has_api_key || exchange.apiKey)
+}
+
+export function isHyperliquidExchange(exchange: Exchange) {
+ return exchange.exchange_type === 'hyperliquid'
+}
+
+/** Prefers the claw402 model, falls back to any enabled model with a credential. */
+export function pickTradingModel(models: AIModel[]) {
+ return (
+ models.find(
+ (model) =>
+ model.provider === 'claw402' &&
+ model.enabled &&
+ modelHasCredential(model)
+ ) ||
+ models.find((model) => model.enabled && modelHasCredential(model)) ||
+ null
+ )
+}
+
+export function pickTradingExchange(exchanges: Exchange[]) {
+ return (
+ exchanges.find(
+ (exchange) =>
+ isHyperliquidExchange(exchange) &&
+ exchange.enabled &&
+ exchangeHasKey(exchange) &&
+ Boolean(exchange.hyperliquidBuilderApproved) &&
+ (exchange.hyperliquidWalletAddr || '').trim() !== ''
+ ) || null
+ )
+}
+
+/**
+ * Resolves a launch-capable AI model, auto-provisioning the beginner claw402
+ * wallet when none is configured yet. Returns null when nothing could be
+ * resolved — the caller routes the user into claw402 setup.
+ */
+export async function resolveLaunchModel(): Promise {
+ let models = await api.getModelConfigs()
+ let model = pickTradingModel(models)
+ if (model) return model
+
+ const onboarding = await api.prepareBeginnerOnboarding()
+ models = await api.getModelConfigs()
+ model =
+ models.find(
+ (item) =>
+ item.id === onboarding.configured_model_id &&
+ item.enabled &&
+ modelHasCredential(item)
+ ) || pickTradingModel(models)
+ if (model) return model
+
+ if (onboarding.configured_model_id && onboarding.private_key) {
+ await api.updateModelConfigs({
+ models: {
+ [onboarding.configured_model_id]: {
+ enabled: true,
+ api_key: onboarding.private_key,
+ custom_api_url: '',
+ custom_model_name: onboarding.default_model,
+ },
+ },
+ })
+ models = await api.getModelConfigs()
+ model =
+ models.find(
+ (item) =>
+ item.id === onboarding.configured_model_id &&
+ item.enabled &&
+ modelHasCredential(item)
+ ) || pickTradingModel(models)
+ }
+
+ return model
+}
+
+/**
+ * Resolves a launch-capable exchange. Returns the exchange or a message
+ * explaining the most specific missing prerequisite.
+ */
+export async function resolveLaunchExchange(): Promise<
+ { exchange: Exchange } | { exchange: null; reason: string }
+> {
+ const exchanges = await api.getExchangeConfigs()
+ const ready = pickTradingExchange(exchanges)
+ if (ready) return { exchange: ready }
+
+ const hyperliquid = exchanges.find(isHyperliquidExchange)
+ if (!hyperliquid) {
+ return {
+ exchange: null,
+ reason:
+ 'No Hyperliquid account is connected. Connect Hyperliquid and authorize the NOFX agent first.',
+ }
+ }
+ if (!hyperliquid.enabled) {
+ return {
+ exchange: null,
+ reason: 'The Hyperliquid account is disabled. Enable it first.',
+ }
+ }
+ if (!exchangeHasKey(hyperliquid)) {
+ return {
+ exchange: null,
+ reason:
+ 'The Hyperliquid agent key is missing. Reconnect Hyperliquid and save the agent wallet.',
+ }
+ }
+ if (!hyperliquid.hyperliquidBuilderApproved) {
+ return {
+ exchange: null,
+ reason:
+ 'Hyperliquid builder authorization is not complete. Finish wallet authorization first.',
+ }
+ }
+ return {
+ exchange: null,
+ reason:
+ 'The Hyperliquid wallet address is missing. Reconnect Hyperliquid first.',
+ }
+}
diff --git a/web/src/lib/launch/types.ts b/web/src/lib/launch/types.ts
new file mode 100644
index 00000000..b8f114d0
--- /dev/null
+++ b/web/src/lib/launch/types.ts
@@ -0,0 +1,44 @@
+export type LaunchCheckStatus = 'ok' | 'failed' | 'warning' | 'skipped'
+
+export type LaunchCheckId =
+ | 'ai_model'
+ | 'ai_wallet'
+ | 'ai_wallet_funds'
+ | 'strategy'
+ | 'exchange_config'
+ | 'exchange_account'
+ | 'exchange_funds'
+
+export interface LaunchCheck {
+ id: LaunchCheckId | string
+ status: LaunchCheckStatus
+ code?: string
+ message?: string
+ required?: number
+ actual?: number
+ asset?: string
+ address?: string
+}
+
+export interface LaunchPreflightResult {
+ ready: boolean
+ checks: LaunchCheck[]
+ min_ai_fee_usdc: number
+ min_trading_usdc: number
+ checked_at: string
+}
+
+/** Guided-setup anchor consumed by the traders page (`?setup=`). */
+export type SetupTarget = 'claw402' | 'hyperliquid' | 'hyperliquid-funds'
+
+export type LaunchOutcome =
+ | { ok: true; traderId: string; warning?: string }
+ | {
+ ok: false
+ kind: 'preflight'
+ message: string
+ preflight: LaunchPreflightResult
+ setupTarget: SetupTarget | null
+ }
+ | { ok: false; kind: 'setup'; message: string; setupTarget: SetupTarget }
+ | { ok: false; kind: 'error'; message: string }
diff --git a/web/src/pages/BeginnerOnboardingPage.tsx b/web/src/pages/BeginnerOnboardingPage.tsx
index 0f8a1d05..fc61676e 100644
--- a/web/src/pages/BeginnerOnboardingPage.tsx
+++ b/web/src/pages/BeginnerOnboardingPage.tsx
@@ -58,6 +58,34 @@ export function BeginnerOnboardingPage() {
void loadOnboarding(true)
}, [])
+ // Poll the balance while the user is depositing so the page updates on its
+ // own. Uses the lightweight current-wallet endpoint (server caches 30s), not
+ // the heavier prepare call that re-writes .env.
+ const walletAddress = data?.address
+ useEffect(() => {
+ if (!walletAddress) return
+ let cancelled = false
+ const timer = setInterval(() => {
+ void api
+ .getCurrentBeginnerWallet()
+ .then((wallet) => {
+ if (cancelled || !wallet.found || !wallet.balance_usdc) return
+ setData((prev) =>
+ prev && prev.address === wallet.address
+ ? { ...prev, balance_usdc: wallet.balance_usdc! }
+ : prev
+ )
+ })
+ .catch(() => {
+ // transient — the manual refresh button still works
+ })
+ }, 15000)
+ return () => {
+ cancelled = true
+ clearInterval(timer)
+ }
+ }, [walletAddress])
+
const noticeText = useMemo(
() =>
isZh
diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx
index 6f7e40e3..361eb42f 100644
--- a/web/src/pages/StrategyStudioPage.tsx
+++ b/web/src/pages/StrategyStudioPage.tsx
@@ -27,12 +27,7 @@ import type {
Strategy,
StrategyConfig,
} from '../types'
-import type {
- AIModel,
- CurrentBeginnerWalletResponse,
- Exchange,
- ExchangeAccountState,
-} from '../types'
+import { launchAutopilot } from '../lib/launch/launchAutopilot'
import type {
MarketSymbol,
VergexHeatmapBin,
@@ -80,85 +75,10 @@ const topNOptions = [5, 6, 7, 8, 9, 10]
const detailBandOptions = ['5', '10', '15', '20']
const claw402BoardLimit = 30
const confidenceOptions = [65, 75, 82]
-const MIN_AI_FEE_USDC = 1
-const MIN_TRADING_USDC = 12
-
-type LaunchSetupTarget = 'claw402' | 'hyperliquid' | 'hyperliquid-funds'
-type FeeWalletSnapshot = Pick<
- CurrentBeginnerWalletResponse,
- 'address' | 'balance_usdc'
->
-
-class LaunchSetupError extends Error {
- target: LaunchSetupTarget
-
- constructor(message: string, target: LaunchSetupTarget) {
- super(message)
- this.name = 'LaunchSetupError'
- this.target = target
- }
-}
const text = (language: string, zh: string, en: string) =>
language === 'zh' ? zh : en
-function modelHasCredential(model: AIModel) {
- return Boolean(
- model.has_api_key ||
- model.apiKey ||
- (model.provider === 'claw402' && model.walletAddress)
- )
-}
-
-function parseUsdc(value?: string | number) {
- if (typeof value === 'number') return Number.isFinite(value) ? value : 0
- if (!value) return 0
- const parsed = Number(value.replace(/[,$\s]/g, ''))
- return Number.isFinite(parsed) ? parsed : 0
-}
-
-function pickTradingModel(models: AIModel[]) {
- return (
- models.find(
- (model) =>
- model.provider === 'claw402' &&
- model.enabled &&
- modelHasCredential(model)
- ) ||
- models.find((model) => model.enabled && modelHasCredential(model)) ||
- null
- )
-}
-
-function isHyperliquidExchange(exchange: Exchange) {
- return exchange.exchange_type === 'hyperliquid'
-}
-
-function hyperliquidWalletAddress(exchange: Exchange) {
- return exchange.hyperliquidWalletAddr || ''
-}
-
-function pickTradingExchange(exchanges: Exchange[]) {
- return (
- exchanges.find(
- (exchange) =>
- isHyperliquidExchange(exchange) &&
- exchange.enabled &&
- modelHasExchangeKey(exchange) &&
- Boolean(exchange.hyperliquidBuilderApproved) &&
- hyperliquidWalletAddress(exchange).trim() !== ''
- ) || null
- )
-}
-
-function modelHasExchangeKey(exchange: Exchange) {
- return Boolean(exchange.has_api_key || exchange.apiKey)
-}
-
-function exchangeAvailableUsdc(state?: ExchangeAccountState) {
- return parseUsdc(state?.available_balance ?? state?.total_equity)
-}
-
type Profile = 'careful' | 'balanced' | 'active'
const profileOptions: Array<{
@@ -1484,236 +1404,48 @@ export function StrategyStudioPage() {
return base
}
- const requireClaw402FeeWallet = async (
- model: AIModel,
- wallet?: FeeWalletSnapshot | null
- ) => {
- if (model.provider !== 'claw402') return
-
- const address = model.walletAddress || wallet?.address || ''
- const balance = parseUsdc(model.balanceUsdc ?? wallet?.balance_usdc)
-
- if (!address.trim()) {
- throw new LaunchSetupError(
- 'Claw402 wallet is not ready. Open the guided setup and create the Base USDC payment wallet first.',
- 'claw402'
- )
- }
-
- if (balance < MIN_AI_FEE_USDC) {
- throw new LaunchSetupError(
- `Claw402 wallet needs at least ${MIN_AI_FEE_USDC} USDC on Base before Autopilot can pay for data and model calls.`,
- 'claw402'
- )
- }
- }
-
- const requireHyperliquidBalance = async (exchange: Exchange) => {
- let accountState: ExchangeAccountState | undefined
- try {
- const response = await api.getExchangeAccountState()
- accountState = response.states[exchange.id]
- } catch {
- throw new LaunchSetupError(
- 'Could not verify the Hyperliquid trading balance. Open the guided setup and refresh the account state first.',
- 'hyperliquid-funds'
- )
- }
-
- if (!accountState) {
- throw new LaunchSetupError(
- 'Could not verify the Hyperliquid trading balance. Open the guided setup and refresh the account state first.',
- 'hyperliquid-funds'
- )
- }
-
- if (accountState.status !== 'ok') {
- throw new LaunchSetupError(
- accountState.error_message ||
- 'Hyperliquid account is not ready. Reconnect the wallet and approve the NOFX Agent.',
- 'hyperliquid'
- )
- }
-
- const balance = exchangeAvailableUsdc(accountState)
- if (balance < MIN_TRADING_USDC) {
- throw new LaunchSetupError(
- `Hyperliquid needs at least ${MIN_TRADING_USDC} USDC available before Autopilot can place its first trade.`,
- 'hyperliquid-funds'
- )
- }
- }
-
- const resolveOneClickModel = async () => {
- let models = await api.getModelConfigs()
- let model = pickTradingModel(models)
- if (model) {
- let wallet: CurrentBeginnerWalletResponse | null = null
- try {
- wallet = await api.getCurrentBeginnerWallet()
- } catch {
- wallet = null
- }
- await requireClaw402FeeWallet(model, wallet)
- return model
- }
-
- const onboarding = await api.prepareBeginnerOnboarding()
- models = await api.getModelConfigs()
- model =
- models.find(
- (item) =>
- item.id === onboarding.configured_model_id &&
- item.enabled &&
- modelHasCredential(item)
- ) || pickTradingModel(models)
-
- if (!model && onboarding.configured_model_id && onboarding.private_key) {
- await api.updateModelConfigs({
- models: {
- [onboarding.configured_model_id]: {
- enabled: true,
- api_key: onboarding.private_key,
- custom_api_url: '',
- custom_model_name: onboarding.default_model,
- },
- },
- })
- models = await api.getModelConfigs()
- model =
- models.find(
- (item) =>
- item.id === onboarding.configured_model_id &&
- item.enabled &&
- modelHasCredential(item)
- ) || pickTradingModel(models)
- }
-
- if (!model) {
- throw new LaunchSetupError(
- 'No enabled AI model is ready. Create or fund the Claw402 wallet first.',
- 'claw402'
- )
- }
-
- await requireClaw402FeeWallet(model, onboarding)
- return model
- }
-
- const resolveOneClickExchange = async () => {
- const exchanges = await api.getExchangeConfigs()
- const exchange = pickTradingExchange(exchanges)
- if (exchange) {
- await requireHyperliquidBalance(exchange)
- return exchange
- }
-
- const hyperliquid = exchanges.find(isHyperliquidExchange)
- if (!hyperliquid) {
- throw new LaunchSetupError(
- 'No Hyperliquid account is ready. Connect Hyperliquid and authorize the NOFX agent first.',
- 'hyperliquid'
- )
- }
- if (!hyperliquid.enabled) {
- throw new LaunchSetupError(
- 'The Hyperliquid account is disabled. Enable it first.',
- 'hyperliquid'
- )
- }
- if (!modelHasExchangeKey(hyperliquid)) {
- throw new LaunchSetupError(
- 'The Hyperliquid agent key is missing. Reconnect Hyperliquid and save the agent wallet.',
- 'hyperliquid'
- )
- }
- if (!hyperliquid.hyperliquidBuilderApproved) {
- throw new LaunchSetupError(
- 'Hyperliquid builder authorization is not complete. Finish wallet authorization first.',
- 'hyperliquid'
- )
- }
- if (!hyperliquidWalletAddress(hyperliquid).trim()) {
- throw new LaunchSetupError(
- 'The Hyperliquid wallet address is missing. Reconnect Hyperliquid first.',
- 'hyperliquid'
- )
- }
-
- throw new LaunchSetupError(
- 'No ready Hyperliquid account found. Check the wallet authorization.',
- 'hyperliquid'
- )
- }
-
const startUnifiedClaw402Agent = async () => {
if (!selectedStrategy) return
- const config = buildUnifiedClaw402Config()
- const traderName = 'NOFX Autopilot'
setSaving(true)
try {
- setEditingConfig(config)
- setHasChanges(true)
-
- await api.updateStrategy(selectedStrategy.id, {
- name: selectedStrategy.name,
- description:
- selectedStrategy.description ||
- 'Autonomous market selection powered by Claw402.ai Signal Lab, liquidation structure, and raw candles.',
- config,
+ // The shared launcher runs the server-side preflight (fresh wallet and
+ // exchange balances) BEFORE ensureStrategy, so a failed launch never
+ // mutates or activates the strategy as a side effect.
+ const outcome = await launchAutopilot({
+ scanIntervalMinutes: 15,
+ ensureStrategy: async () => {
+ const config = buildUnifiedClaw402Config()
+ setEditingConfig(config)
+ await api.updateStrategy(selectedStrategy.id, {
+ name: selectedStrategy.name,
+ description:
+ selectedStrategy.description ||
+ 'Autonomous market selection powered by Claw402.ai Signal Lab, liquidation structure, and raw candles.',
+ config,
+ })
+ await api.activateStrategy(selectedStrategy.id)
+ return selectedStrategy.id
+ },
})
- await api.activateStrategy(selectedStrategy.id)
- const [model, exchange] = await Promise.all([
- resolveOneClickModel(),
- resolveOneClickExchange(),
- ])
-
- const traderRequest = {
- name: traderName,
- ai_model_id: model.id,
- exchange_id: exchange.id,
- strategy_id: selectedStrategy.id,
- scan_interval_minutes: 15,
- is_cross_margin: true,
- show_in_competition: true,
- }
-
- const existingTraders = await api.getTraders(true)
- const existingAutopilot = existingTraders.find(
- (trader) => trader.trader_name === traderName
- )
- const autopilot = existingAutopilot
- ? await api.updateTrader(existingAutopilot.trader_id, traderRequest)
- : await api.createTrader(traderRequest)
-
- if (autopilot.startup_warning) {
- notify.warning(autopilot.startup_warning)
- }
-
- if (!autopilot.is_running) {
- await api.startTrader(autopilot.trader_id)
- }
- notify.success(`${traderName} started`)
- setHasChanges(false)
- await loadStrategies(selectedStrategy.id)
- navigate(buildDashboardPath(autopilot.trader_id))
- } catch (err) {
- if (err instanceof LaunchSetupError) {
- notify.error(err.message)
- navigate(
- err.target === 'claw402'
- ? `${ROUTES.traders}?setup=claw402`
- : err.target === 'hyperliquid'
- ? `${ROUTES.traders}?setup=hyperliquid`
- : ROUTES.traders
- )
+ if (!outcome.ok) {
+ notify.error(outcome.message)
+ const setupTarget =
+ outcome.kind === 'error' ? null : outcome.setupTarget
+ if (setupTarget) {
+ navigate(`${ROUTES.traders}?setup=${setupTarget}`)
+ }
return
}
- notify.error(
- err instanceof Error ? err.message : 'Failed to launch NOFX Autopilot'
- )
+
+ if (outcome.warning) {
+ notify.warning(outcome.warning)
+ }
+ notify.success('NOFX Autopilot started')
+ setHasChanges(false)
+ await loadStrategies(selectedStrategy.id)
+ navigate(buildDashboardPath(outcome.traderId))
} finally {
setSaving(false)
}
diff --git a/web/src/types/trading.ts b/web/src/types/trading.ts
index e89f3a82..f2bd17b3 100644
--- a/web/src/types/trading.ts
+++ b/web/src/types/trading.ts
@@ -13,6 +13,13 @@ export interface SystemStatus {
ai_provider: string
strategy_type?: 'ai_trading' | 'grid_trading'
grid_symbol?: string
+ /** Runtime health: true when AI failed repeatedly and no new positions open. */
+ safe_mode?: boolean
+ safe_mode_reason?: string
+ /** Claw402 AI fee wallet health, observed by the run loop. */
+ ai_wallet_status?: 'ok' | 'low' | 'empty' | 'unknown'
+ ai_wallet_balance_usdc?: number
+ ai_wallet_checked_at?: string
}
export interface AccountInfo {