feat: unify autopilot launch on server preflight with live balance polling

- New shared launch module (web/src/lib/launch): preflight client, model/
  exchange resolution, and a single launchAutopilot orchestrator used by both
  Strategy Studio and the guided launch panel — the two previously duplicated
  and divergent launch implementations are gone.
- Preflight runs BEFORE any strategy mutation, so a failed launch no longer
  rewrites/activates the strategy as a side effect; readiness and minimums
  come from the server (live balances) instead of stale client caches.
- Guided panel polls preflight every 20s so deposits are detected without
  manual refresh; step metas show live balances and required minimums; the
  beginner wallet page polls its balance while the deposit screen is open.
- Every failure now routes to a guided fix: hyperliquid-funds gets its own
  setup anchor (scrolls to the launch panel), start rejections surface the
  server's preflight reason (including manual restarts from the trader list),
  and structured error bodies flow through ApiError.errorData.
- Terminal dashboard shows a persistent runtime banner when the AI fee wallet
  runs low/empty or the trader enters safe mode.
- Vitest coverage for setup-target routing, readiness helpers, and the
  launch orchestration order (no side effects before preflight).
This commit is contained in:
tinkle-community
2026-07-05 20:38:51 +09:00
parent b17d97effc
commit a744328313
14 changed files with 996 additions and 400 deletions

View File

@@ -303,6 +303,24 @@ export function TerminalDashboard({
navSlot,
)}
<div className="tm-box" style={{ maxWidth: 1280, margin: '0 auto', border: 'none' }}>
{/* 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') && (
<div className="tm-mono" style={{ display: 'flex', gap: 10, alignItems: 'center', margin: '8px 14px 0', padding: '8px 12px', fontSize: 11, border: '1px solid var(--tm-down)', color: 'var(--tm-down)', background: 'rgba(200,60,40,0.06)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600 }}>
{status.ai_wallet_status === 'empty'
? 'AI fee wallet is out of USDC — decisions are failing.'
: status.ai_wallet_status === 'low'
? `AI fee wallet is low (${(status.ai_wallet_balance_usdc ?? 0).toFixed(2)} USDC) — top up soon.`
: 'Safe mode: AI failed repeatedly, no new positions are being opened.'}
</span>
<span style={{ color: 'var(--tm-ink-2)' }}>
{status.ai_wallet_status === 'empty' || status.ai_wallet_status === 'low'
? 'Deposit Base USDC to the Claw402 wallet, the trader recovers automatically.'
: status.safe_mode_reason || ''}
</span>
</div>
)}
{/* config / identity strip — first row, flows directly under the global nav */}
<div className="tm-mono" style={{ display: 'flex', gap: 16, padding: '6px 14px', fontSize: 11, color: 'var(--tm-ink-2)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 500 }}>{selectedTrader?.trader_name ?? 'NOFX'}</span>

View File

@@ -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
}

View File

@@ -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<LaunchPreflightResult | null>(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 ? (
<div className="flex flex-wrap items-center gap-3">
@@ -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 (
<button
type="button"
onClick={launchAutopilot}
onClick={() => 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 (
<section className="overflow-hidden rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter">
<section
id="autopilot-launch-panel"
className="overflow-hidden rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter"
>
<div className="grid gap-0 xl:grid-cols-[1.05fr_0.95fr]">
<div className="p-5 md:p-6">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">

View File

@@ -10,9 +10,10 @@ function throwApiError(
message: string,
errorKey?: string,
errorParams?: Record<string, string>,
statusCode?: number
statusCode?: number,
errorData?: Record<string, any>
): 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
)
}
},

View File

@@ -22,24 +22,29 @@ export interface ApiResponse<T = any> {
errorKey?: string
errorParams?: Record<string, string>
statusCode?: number
/** Full error response body for endpoints that return structured details. */
errorData?: Record<string, any>
}
export class ApiError extends Error {
errorKey?: string
errorParams?: Record<string, string>
statusCode?: number
errorData?: Record<string, any>
constructor(
message: string,
errorKey?: string,
errorParams?: Record<string, string>,
statusCode?: number
statusCode?: number,
errorData?: Record<string, any>
) {
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,
}
}

View File

@@ -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<AIModel> = { 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()
})
})

View File

@@ -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<typeof import('./preflight')>()),
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()
})
})

View File

@@ -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<string>
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<LaunchOutcome> {
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<string> {
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
}

View File

@@ -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<LaunchPreflightResult> {
const result = await httpClient.post<LaunchPreflightResult>(
`${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<LaunchPreflightResult> {
const result = await httpClient.get<LaunchPreflightResult>(
`${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
}

View File

@@ -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<AIModel | null> {
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.',
}
}

View File

@@ -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 }

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 {