mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 16:56:56 +08:00
feat: verify wallet and exchange balances before autopilot launch
Requires the Claw402 fee wallet to exist with at least 1 USDC on Base and the Hyperliquid account to show at least 12 USDC available before launch; failures route back to the matching guided-setup step.
This commit is contained in:
@@ -27,7 +27,12 @@ import type {
|
|||||||
Strategy,
|
Strategy,
|
||||||
StrategyConfig,
|
StrategyConfig,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import type { AIModel, Exchange } from '../types'
|
import type {
|
||||||
|
AIModel,
|
||||||
|
CurrentBeginnerWalletResponse,
|
||||||
|
Exchange,
|
||||||
|
ExchangeAccountState,
|
||||||
|
} from '../types'
|
||||||
import type {
|
import type {
|
||||||
MarketSymbol,
|
MarketSymbol,
|
||||||
VergexHeatmapBin,
|
VergexHeatmapBin,
|
||||||
@@ -36,7 +41,7 @@ import type {
|
|||||||
VergexSignalItem,
|
VergexSignalItem,
|
||||||
VergexSignalLabResponse,
|
VergexSignalLabResponse,
|
||||||
} from '../lib/api/data'
|
} from '../lib/api/data'
|
||||||
import { buildDashboardPath } from '../router/paths'
|
import { buildDashboardPath, ROUTES } from '../router/paths'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||||
|
|
||||||
@@ -75,12 +80,41 @@ const topNOptions = [5, 6, 7, 8, 9, 10]
|
|||||||
const detailBandOptions = ['5', '10', '15', '20']
|
const detailBandOptions = ['5', '10', '15', '20']
|
||||||
const claw402BoardLimit = 30
|
const claw402BoardLimit = 30
|
||||||
const confidenceOptions = [65, 75, 82]
|
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) =>
|
const text = (language: string, zh: string, en: string) =>
|
||||||
language === 'zh' ? zh : en
|
language === 'zh' ? zh : en
|
||||||
|
|
||||||
function modelHasCredential(model: AIModel) {
|
function modelHasCredential(model: AIModel) {
|
||||||
return Boolean(model.has_api_key || model.apiKey)
|
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[]) {
|
function pickTradingModel(models: AIModel[]) {
|
||||||
@@ -121,6 +155,10 @@ function modelHasExchangeKey(exchange: Exchange) {
|
|||||||
return Boolean(exchange.has_api_key || exchange.apiKey)
|
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'
|
type Profile = 'careful' | 'balanced' | 'active'
|
||||||
|
|
||||||
const profileOptions: Array<{
|
const profileOptions: Array<{
|
||||||
@@ -379,7 +417,8 @@ function signalBiasInfo(bias: string | undefined) {
|
|||||||
return {
|
return {
|
||||||
label: 'Neutral',
|
label: 'Neutral',
|
||||||
hint: 'Neutral bias',
|
hint: 'Neutral bias',
|
||||||
classes: 'border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper text-nofx-text-muted',
|
classes:
|
||||||
|
'border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper text-nofx-text-muted',
|
||||||
icon: Target,
|
icon: Target,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1388,7 +1427,11 @@ export function StrategyStudioPage() {
|
|||||||
notify.success(
|
notify.success(
|
||||||
successMessage ||
|
successMessage ||
|
||||||
(activateAfter
|
(activateAfter
|
||||||
? text(language, 'Strategy saved and activated', 'Strategy saved and activated')
|
? text(
|
||||||
|
language,
|
||||||
|
'Strategy saved and activated',
|
||||||
|
'Strategy saved and activated'
|
||||||
|
)
|
||||||
: text(language, 'Strategy saved', 'Strategy saved'))
|
: text(language, 'Strategy saved', 'Strategy saved'))
|
||||||
)
|
)
|
||||||
await loadStrategies(selectedStrategy.id)
|
await loadStrategies(selectedStrategy.id)
|
||||||
@@ -1441,10 +1484,79 @@ export function StrategyStudioPage() {
|
|||||||
return base
|
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 () => {
|
const resolveOneClickModel = async () => {
|
||||||
let models = await api.getModelConfigs()
|
let models = await api.getModelConfigs()
|
||||||
let model = pickTradingModel(models)
|
let model = pickTradingModel(models)
|
||||||
if (model) return model
|
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()
|
const onboarding = await api.prepareBeginnerOnboarding()
|
||||||
models = await api.getModelConfigs()
|
models = await api.getModelConfigs()
|
||||||
@@ -1478,46 +1590,59 @@ export function StrategyStudioPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
throw new Error(
|
throw new LaunchSetupError(
|
||||||
'No enabled AI model is ready. Create or fund the Claw402 wallet first.'
|
'No enabled AI model is ready. Create or fund the Claw402 wallet first.',
|
||||||
|
'claw402'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireClaw402FeeWallet(model, onboarding)
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveOneClickExchange = async () => {
|
const resolveOneClickExchange = async () => {
|
||||||
const exchanges = await api.getExchangeConfigs()
|
const exchanges = await api.getExchangeConfigs()
|
||||||
const exchange = pickTradingExchange(exchanges)
|
const exchange = pickTradingExchange(exchanges)
|
||||||
if (exchange) return exchange
|
if (exchange) {
|
||||||
|
await requireHyperliquidBalance(exchange)
|
||||||
|
return exchange
|
||||||
|
}
|
||||||
|
|
||||||
const hyperliquid = exchanges.find(isHyperliquidExchange)
|
const hyperliquid = exchanges.find(isHyperliquidExchange)
|
||||||
if (!hyperliquid) {
|
if (!hyperliquid) {
|
||||||
throw new Error(
|
throw new LaunchSetupError(
|
||||||
'No Hyperliquid account is ready. Connect Hyperliquid and authorize the NOFX agent first.'
|
'No Hyperliquid account is ready. Connect Hyperliquid and authorize the NOFX agent first.',
|
||||||
|
'hyperliquid'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!hyperliquid.enabled) {
|
if (!hyperliquid.enabled) {
|
||||||
throw new Error('The Hyperliquid account is disabled. Enable it first.')
|
throw new LaunchSetupError(
|
||||||
|
'The Hyperliquid account is disabled. Enable it first.',
|
||||||
|
'hyperliquid'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!modelHasExchangeKey(hyperliquid)) {
|
if (!modelHasExchangeKey(hyperliquid)) {
|
||||||
throw new Error(
|
throw new LaunchSetupError(
|
||||||
'The Hyperliquid agent key is missing. Reconnect Hyperliquid and save the agent wallet.'
|
'The Hyperliquid agent key is missing. Reconnect Hyperliquid and save the agent wallet.',
|
||||||
|
'hyperliquid'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!hyperliquid.hyperliquidBuilderApproved) {
|
if (!hyperliquid.hyperliquidBuilderApproved) {
|
||||||
throw new Error(
|
throw new LaunchSetupError(
|
||||||
'Hyperliquid builder authorization is not complete. Finish wallet authorization first.'
|
'Hyperliquid builder authorization is not complete. Finish wallet authorization first.',
|
||||||
|
'hyperliquid'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!hyperliquidWalletAddress(hyperliquid).trim()) {
|
if (!hyperliquidWalletAddress(hyperliquid).trim()) {
|
||||||
throw new Error(
|
throw new LaunchSetupError(
|
||||||
'The Hyperliquid wallet address is missing. Reconnect Hyperliquid first.'
|
'The Hyperliquid wallet address is missing. Reconnect Hyperliquid first.',
|
||||||
|
'hyperliquid'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new LaunchSetupError(
|
||||||
'No ready Hyperliquid account found. Check the wallet authorization.'
|
'No ready Hyperliquid account found. Check the wallet authorization.',
|
||||||
|
'hyperliquid'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1575,6 +1700,17 @@ export function StrategyStudioPage() {
|
|||||||
await loadStrategies(selectedStrategy.id)
|
await loadStrategies(selectedStrategy.id)
|
||||||
navigate(buildDashboardPath(autopilot.trader_id))
|
navigate(buildDashboardPath(autopilot.trader_id))
|
||||||
} catch (err) {
|
} 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
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
notify.error(
|
notify.error(
|
||||||
err instanceof Error ? err.message : 'Failed to launch NOFX Autopilot'
|
err instanceof Error ? err.message : 'Failed to launch NOFX Autopilot'
|
||||||
)
|
)
|
||||||
@@ -2381,7 +2517,11 @@ export function StrategyStudioPage() {
|
|||||||
<div className="rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg-lighter p-4">
|
<div className="rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg-lighter p-4">
|
||||||
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-nofx-text">
|
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-nofx-text">
|
||||||
<Shield className="h-4 w-4 text-nofx-success" />
|
<Shield className="h-4 w-4 text-nofx-success" />
|
||||||
{text(language, 'Trading parameters', 'Trading parameters')}
|
{text(
|
||||||
|
language,
|
||||||
|
'Trading parameters',
|
||||||
|
'Trading parameters'
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
@@ -2424,7 +2564,11 @@ export function StrategyStudioPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-xs text-nofx-text-muted">
|
<span className="text-xs text-nofx-text-muted">
|
||||||
{text(language, 'Entry confidence', 'Entry confidence')}
|
{text(
|
||||||
|
language,
|
||||||
|
'Entry confidence',
|
||||||
|
'Entry confidence'
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={risk.min_confidence}
|
value={risk.min_confidence}
|
||||||
|
|||||||
Reference in New Issue
Block a user