'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useConnect, useSignMessage } from 'wagmi'
import {
getUserPublic,
getUser,
getTelegramStatus,
setUserSettings,
setHlApiKey,
setManualWindow,
setAutoTrade,
subscribe,
type UserSettings,
} from '@/lib/api'
import { useDashboardStore } from '@/store/dashboard'
import {
signRequest,
getOrCreateViewEnvelope,
getCachedViewEnvelope,
} from '@/lib/signedRequest'
import { walletErrorLabel } from '@/lib/walletError'
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
const DEFAULT_SETTINGS: UserSettings = {
leverage: 3, position_size_usd: 20, take_profit_pct: 2, stop_loss_pct: 1.5,
min_confidence: 70, daily_budget_usd: 15, sys2_leverage: 2,
sys2_mode: 'standard',
active_from: null, active_until: null,
trump_enabled: false, macro_enabled: false,
}
// The backend treats active_from/active_until as a DAILY RECURRING time window
// (it extracts only .time() and compares with the current UTC time). Storing a
// full datetime-local value was misleading — the date part was silently ignored
// so users thought they were setting a date range when they were setting a
// time-of-day schedule. We now use type="time" and store as a fixed-epoch ISO
// so the backend's .time() extraction gives the right HH:MM:SS.
function isoToLocalInput(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
// Return just HH:MM for the time input
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
}
function localInputToIso(v: string): string | null {
if (!v) return null
// v is HH:MM from . Store as 2000-01-01T{HH:MM}:00Z so
// the backend's .time() extraction returns the correct UTC time.
const [hh, mm] = v.split(':')
if (!hh || !mm) return null
return `2000-01-01T${hh.padStart(2,'0')}:${mm.padStart(2,'0')}:00Z`
}
export default function BotConfigPanel() {
useLocale() // keep next-intl context hydrated even though i18n is shelved
const { address, isConnected } = useAccount()
const { connectAsync, connectors } = useConnect()
const { signMessageAsync } = useSignMessage()
const {
isSubscribed, hlApiKeySet, hlApiKeyMasked,
setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode: setPaperModeStore,
} = useDashboardStore()
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
const [tpConfigured, setTpConfigured] = useState(false)
const [slConfigured, setSlConfigured] = useState(false)
const [useBudget, setUseBudget] = useState(false)
const [useSchedule, setUseSchedule] = useState(false)
const [fromLocal, setFromLocal] = useState('')
const [untilLocal, setUntilLocal] = useState('')
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
const [saveErr, setSaveErr] = useState('')
const [apiKey, setApiKey] = useState('')
const [keyState, setKeyState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
const [keyErr, setKeyErr] = useState('')
const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [subErr, setSubErr] = useState('')
const [subPaperChoice, setSubPaperChoice] = useState<'paper' | 'live'>('paper')
const [manualUntil, setManualUntil] = useState(null)
const [mwState, setMwState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [mwErr, setMwErr] = useState('')
const [mwTick, setMwTick] = useState(0)
const [paperMode, setPaperMode] = useState(false)
const [autoTrade, setAutoTrade_] = useState(false)
const [atState, setAtState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [showAdvanced, setShowAdvanced] = useState(false)
const [mounted, setMounted] = useState(false)
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
const [loadErr, setLoadErr] = useState('')
const [connectErr, setConnectErr] = useState('')
// Telegram binding state. Macro Vibes (System-2) is manage-only via the
// Telegram /adopt command, so a Macro-only user who hasn't bound Telegram
// cannot actually hand any position to the bot — readiness must reflect that.
// The unauthenticated status call returns only { bound } (no chat_id), which
// is all we need here.
const [tgBound, setTgBound] = useState(null)
useEffect(() => { setMounted(true) }, [])
useEffect(() => {
if (!address) { setTgBound(null); return }
let cancelled = false
getTelegramStatus(address.toLowerCase())
.then(s => { if (!cancelled) setTgBound(!!s.bound) })
.catch(() => { if (!cancelled) setTgBound(null) })
return () => { cancelled = true }
}, [address])
// B33: when the connected wallet changes, immediately wipe all private
// settings so the previous wallet's config never leaks into the new one.
useEffect(() => {
setSettings(DEFAULT_SETTINGS)
setPaperMode(false)
setAutoTrade_(false)
setTpConfigured(false)
setSlConfigured(false)
setUseBudget(false)
setUseSchedule(false)
setFromLocal('')
setUntilLocal('')
setManualUntil(null)
setApiKey('')
setDirty(false)
setSaveState('idle'); setSaveErr('')
setKeyState('idle'); setKeyErr('')
setSubState('idle'); setSubErr('')
setLoadState('idle'); setLoadErr('')
setConnectErr('')
}, [address])
function applyUserPayload(u: {
active: boolean
hl_api_key_set: boolean
hl_api_key_masked: string | null
paper_mode?: boolean
auto_trade?: boolean
settings: UserSettings
manual_window_until?: string | null
}) {
setSubscribed(u.active)
setPaperMode(!!u.paper_mode)
setPaperModeStore(!!u.paper_mode) // sync to global store for B40/B41
setAutoTrade_(!!u.auto_trade)
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
setManualUntil(u.manual_window_until ?? null)
if (u.settings) {
const s = u.settings
setTpConfigured(s.take_profit_pct != null)
setSlConfigured(s.stop_loss_pct != null)
const hasBudget = s.daily_budget_usd != null
setUseBudget(hasBudget)
setBotReadiness(u.hl_api_key_set ? 'saved' : 'unknown')
setSettings({
...s,
take_profit_pct: s.take_profit_pct ?? 2,
stop_loss_pct: s.stop_loss_pct ?? 1.5,
daily_budget_usd: s.daily_budget_usd ?? 15,
trump_enabled: s.trump_enabled ?? false,
macro_enabled: s.macro_enabled ?? false,
})
const hasSched = s.active_from != null && s.active_until != null
setUseSchedule(hasSched)
setFromLocal(isoToLocalInput(s.active_from))
setUntilLocal(isoToLocalInput(s.active_until))
}
}
useEffect(() => {
if (!mounted || !isConnected || !address) return
let cancelled = false
;(async () => {
const pub = await getUserPublic(address.toLowerCase()).catch(() => null)
if (!pub || cancelled) return
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
setAutoTrade_(!!pub.auto_trade)
if (!pub.active) return
const cached = getCachedViewEnvelope('view_user', address)
if (!cached) return
const u = await getUser(address, cached).catch(() => null)
if (!u || cancelled) return
applyUserPayload(u)
setLoadState('loaded')
})()
return () => { cancelled = true }
}, [address, isConnected, mounted]) // eslint-disable-line react-hooks/exhaustive-deps
async function handleLoadSettings() {
if (!address) return
setLoadErr(''); setLoadState('loading')
try {
// Read-only identity check — go straight to MetaMask, no pre-confirm sheet.
// getOrCreateViewEnvelope caches the result for 4 min so repeat visits are free.
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
const u = await getUser(address, env)
applyUserPayload(u)
setLoadState('loaded')
} catch (e: unknown) {
setLoadErr(walletErrorLabel(e, 'Cancelled', 120))
setLoadState('err')
}
}
async function refreshUserState(wallet: string) {
const pub = await getUserPublic(wallet.toLowerCase())
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
setAutoTrade_(!!pub.auto_trade)
return pub
}
async function flipAutoTrade(on: boolean) {
if (!address || atState !== 'idle') return
const ok = await confirmSign({
label: on ? 'Enable Auto-Trade' : 'Disable Auto-Trade',
description: on
? 'Bot will open real positions on Hyperliquid when qualifying Trump signals fire. Stop-loss and de-risking remain active.'
: 'Bot stops opening new trades. Risk controls on existing positions keep running.',
danger: on,
})
if (!ok) return
setAtState('signing')
try {
const env = await signRequest({ action: 'set_auto_trade', wallet: address, body: { enabled: on }, signMessageAsync })
setAtState('saving')
const r = await setAutoTrade(env, on)
setAutoTrade_(r.auto_trade)
setAtState('idle')
} catch (e: unknown) {
console.error('set_auto_trade failed', e)
setAtState('err')
setTimeout(() => setAtState('idle'), 3000)
}
}
function updateSettings(patch: Partial) {
setSettings(s => ({ ...s, ...patch }))
setDirty(true)
}
async function handleSubscribe() {
if (!address) return
const paperMode = subPaperChoice === 'paper'
const ok = await confirmSign({
label: paperMode ? 'Start in paper mode' : 'Start live trading',
description: paperMode
? 'Trades are simulated end-to-end — no funds are at risk. You can switch to live at any time from the settings panel.'
: 'Start live trading. You\'ll need to add a Hyperliquid API key (next step) before the bot can open any real position.',
danger: !paperMode,
})
if (!ok) return
setSubErr(''); setSubState('signing')
try {
const signedBody = paperMode ? { paper_mode: true } : null
const env = await signRequest({ action: 'subscribe', wallet: address, body: signedBody, signMessageAsync })
setSubState('saving')
await subscribe(env, { paper_mode: paperMode })
setSubscribed(true)
setPaperMode(paperMode)
void refreshUserState(address)
setSubState('idle')
setTpConfigured(false); setSlConfigured(false)
setUseBudget(false); setLoadState('loaded')
} catch (e: unknown) {
setSubErr(walletErrorLabel(e, 'Cancelled', 100))
setSubState('err')
}
}
async function handleSaveSettings() {
if (!address) return
const ok = await confirmSign({
label: 'Save trading settings',
description: 'Applied to all new trades going forward. Open positions are not affected.',
})
if (!ok) return
setSaveErr(''); setSaveState('signing')
try {
let schedFrom: string | null = null
let schedUntil: string | null = null
if (useSchedule) {
schedFrom = localInputToIso(fromLocal)
schedUntil = localInputToIso(untilLocal)
if (!schedFrom || !schedUntil) { setSaveErr('Pick both a start and end time'); setSaveState('err'); return }
// Cross-midnight windows (e.g. 22:00–02:00) are VALID — the backend's
// _is_in_active_window treats af_t > au_t as a wrap-around window
// (now >= from OR now <= until). Only reject when start === end, which
// would describe a zero-length (or full-day-ambiguous) window.
if (new Date(schedUntil).getTime() === new Date(schedFrom).getTime()) { setSaveErr('Start and end can’t be the same time'); setSaveState('err'); return }
}
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
if (trumpOn) {
if (tp == null || tp < 0.1 || tp > 50) { setSaveErr('Take profit must be 0.1–50%'); setSaveState('err'); return }
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr('Stop loss must be 0.1–50%'); setSaveState('err'); return }
}
if (useBudget && (bd == null || bd <= 0 || bd > 100000)) { setSaveErr('Daily budget must be > $0'); setSaveState('err'); return }
const payload: UserSettings = { ...settings, daily_budget_usd: useBudget ? bd : null, active_from: schedFrom, active_until: schedUntil }
const env = await signRequest({ action: 'set_settings', wallet: address, body: payload, signMessageAsync })
setSaveState('saving')
await setUserSettings(env, payload)
setSettings(payload)
setTpConfigured(tp != null); setSlConfigured(sl != null)
setBotReadiness('saved'); setDirty(false)
setSaveState('ok')
setTimeout(() => setSaveState('idle'), 2500)
} catch (e: unknown) {
setSaveErr(walletErrorLabel(e, 'Cancelled', 100))
setSaveState('err')
}
}
async function handleSaveKey() {
if (!address || !apiKey.trim()) return
const trimmed = apiKey.trim()
if (!trimmed.startsWith('0x') || trimmed.length !== 66) {
setKeyErr('Must be 0x + 64 hex chars'); setKeyState('err'); return
}
const ok = await confirmSign({
label: 'Link Hyperliquid API key',
description: 'Stored encrypted on the server. Used by the bot to open and close positions only — no withdrawal access.',
danger: true,
})
if (!ok) return
setKeyErr(''); setKeyState('signing')
try {
const env = await signRequest({ action: 'set_hl_api_key', wallet: address, body: { api_key: trimmed }, signMessageAsync })
setKeyState('saving')
const res = await setHlApiKey(env, trimmed)
setHlApiKeySet(true, res.masked_key)
setBotReadiness('saved')
void refreshUserState(address)
setApiKey(''); setKeyState('ok')
} catch (e: unknown) {
setKeyErr(walletErrorLabel(e, 'Cancelled', 100))
setKeyState('err')
}
}
const handleConnectWallet = useCallback(async () => {
setConnectErr('')
try {
const connector = await getFirstReadyConnector(connectors)
if (!connector) { setConnectErr('No wallet connector is available right now.'); return }
await connectAsync({ connector })
} catch (err: unknown) {
setConnectErr(walletConnectErrorLabel(err))
}
}, [connectAsync, connectors])
async function handleManualWindow(hours: number) {
if (!address) return
const ok = await confirmSign(hours === 0
? {
label: 'Cancel override window',
description: 'The bot will stop accepting new trades from this override. Any open position risk controls (stop-loss, de-risk) remain active.',
}
: {
label: `Open ${hours}h override window`,
description: `The bot will accept qualifying signals for the next ${hours} hour${hours > 1 ? 's' : ''}, regardless of your trading schedule. Use this for high-conviction catalysts (e.g. CPI, FOMC). Stop-loss is always active.`,
danger: true,
}
)
if (!ok) return
setMwErr(''); setMwState('signing')
try {
const env = await signRequest({ action: 'set_manual_window', wallet: address, body: { hours }, signMessageAsync })
setMwState('saving')
const r = await setManualWindow(env, hours)
setManualUntil(r.manual_window_until); setMwState('idle')
} catch (e: unknown) {
setMwErr(walletErrorLabel(e, 'Cancelled', 100))
setMwState('err')
}
}
async function handleUpgradeToLive() {
if (!address) return
const ok = await confirmSign({
label: 'Switch to live trading',
description: 'Switches to live trading. Auto-Trade is turned OFF automatically — re-enable it explicitly. You still need a Hyperliquid API key before the bot can trade.',
danger: true,
})
if (!ok) return
setSubErr(''); setSubState('signing')
try {
// paper_mode=false → signedBody=null (same as original live subscribe)
const env = await signRequest({ action: 'subscribe', wallet: address, body: null, signMessageAsync })
setSubState('saving')
await subscribe(env, { paper_mode: false })
setPaperMode(false)
void refreshUserState(address)
setSubState('idle')
} catch (e: unknown) {
setSubErr(walletErrorLabel(e, 'Cancelled', 100))
setSubState('err')
}
}
useEffect(() => {
if (!manualUntil) return
const id = setInterval(() => setMwTick(t => t + 1), 15000)
return () => clearInterval(id)
}, [manualUntil])
// ── Not connected ──────────────────────────────────────────────────────────
if (!mounted || !isConnected) {
return (
Connect your wallet to continue
Your subscription, API key, and trade settings are wallet-bound. No account needed — just sign a message.
{connectErr &&
{connectErr}
}
)
}
// ── helpers ────────────────────────────────────────────────────────────────
const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' }) => (
)
const trumpOn = !!settings.trump_enabled
const macroOn = !!settings.macro_enabled
// ── Not subscribed ─────────────────────────────────────────────────────────
if (!isSubscribed) {
return (
Set up your bot
Trade-only API key — no withdrawals. Try Paper first, or Live if you already have a key.
{/* Paper / Live choice */}
{subPaperChoice === 'paper' ? '📝 Paper mode — simulated trades, no real funds' : '💰 Live trading — real positions on Hyperliquid'}
{subPaperChoice === 'paper'
? 'Nothing is at risk. No Hyperliquid API key needed. You can switch to live at any time.'
: 'Real positions will be opened and managed on Hyperliquid. A trade-only API key is required before the first trade.'}
Paper {
setSubPaperChoice(v ? 'live' : 'paper')
// Clear any stale subscribe-error from a previous attempt so
// the user sees a clean state for the new paper/live choice.
if (subState === 'err') { setSubState('idle'); setSubErr('') }
}}
tone="amber"
/>
Live
{subState === 'err' && {subErr}}
)
}
// ── Subscribed but settings not loaded yet ─────────────────────────────────
if (loadState !== 'loaded') {
return (
Sign in to view your settings
Settings are wallet-private. Sign once to load — cached for 4 min.
{loadState === 'err' && {loadErr}}
)
}
// ── Main config (subscribed + loaded) ──────────────────────────────────────
// Readiness check — shown at bottom near save button
const missingItems: string[] = []
if (!paperMode && !hlApiKeySet) missingItems.push('link a Hyperliquid API key')
if (!trumpOn && !macroOn) missingItems.push('enable Trump Signal or Macro Vibes below')
if (trumpOn && !tpConfigured) missingItems.push('set a Trump Signal take-profit %')
if (trumpOn && !slConfigured) missingItems.push('set a Trump Signal stop-loss %')
// Macro Vibes is manage-only through the Telegram /adopt command. If Macro is
// the only enabled system and Telegram isn't bound, the bot can't manage any
// position — so "ready" would be a lie. (tgBound === null = status unknown /
// still loading: don't block on it.)
if (macroOn && !trumpOn && tgBound === false) {
missingItems.push('connect Telegram (Settings → Telegram) so the bot can manage Macro positions via /adopt')
}
const botReady = missingItems.length === 0
// Manual window countdown
void mwTick
const untilMs = manualUntil ? new Date(manualUntil).getTime() : 0
const remainingMin = untilMs ? Math.max(0, Math.round((untilMs - Date.now()) / 60000)) : 0
const armed = remainingMin > 0
const remainingLabel = remainingMin >= 60 ? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m` : `${remainingMin}m`
const mwBusy = mwState === 'signing' || mwState === 'saving'
const saveBtnLabel =
saveState === 'signing' ? 'Sign in wallet…'
: saveState === 'saving' ? 'Saving…'
: saveState === 'ok' ? '✓ Saved'
: 'Save changes'
return (
{/* Connector — takes all remaining space between this and next step */}
{!isLast && (
)}
)
})}
{/* Bottom progress bar */}
)}
{/* ── HL API Key ─────────────────────────────────────────────────────── */}
{/* Status dot */}
{(paperMode || hlApiKeySet) ? '✓' : '◇'}
Hyperliquid API wallet
{paperMode
? 📝 Paper mode — no real money involved. Add an API key below to switch to live.
: hlApiKeySet && !apiKey
? <>{hlApiKeyMasked ?? '···'} · trade-only · cannot withdraw>
: <>Don't have Hyperliquid?{' '}
Sign up free ↗
{' '}· Then go to API → generate a trade-only key → paste below.>}
{/* Paper mode: show upgrade path instead of key input */}
{paperMode && (
)}
{/* Live mode: show key input */}
{!paperMode && (hlApiKeySet && !apiKey ? (
) : (
Saved. Enable Auto-Trade below, then try a paper trade first to confirm the live path works.
}
{!paperMode && !hlApiKeySet && (
⚠️
Do not paste your main wallet private key.{' '}
This field only accepts a Hyperliquid trade-only API key — generate one at{' '}
app.hyperliquid.xyz/API
{' '}→ “Generate API wallet”. A trade-only key cannot withdraw funds.
{autoTrade
? 'ON — bot opens positions automatically when qualifying Trump signals fire.'
: 'OFF — signals are shown but no trades are opened. Turn on when ready to go live.'}
)}
{/* ── Trump Signal ───────────────────────────────────────────────────── */}
{/* Header */}
Trump Signal
{trumpOn
? 'Opens positions when a Trump Truth Social post fires a signal.'
: 'Disabled — bot ignores Trump posts.'}
updateSettings({ trump_enabled: v })} tone="amber" />
{/* Form — only when enabled */}
{trumpOn && (
Per-trade size
How much to bet per trade. At {settings.leverage}×, HL holds ~${(settings.position_size_usd / settings.leverage).toFixed(0)} as collateral.
Leverage
How aggressive the position is. Start low (2–3×) and increase once you've seen the bot trade live.
updateSettings({ leverage: +e.target.value })} />
1×25×50×
{settings.leverage}×
{settings.leverage > 10 && (
{settings.leverage}× is high for event-driven signals. A 1.5% stop-loss at {settings.leverage}× means the trade closes on a {(1.5 / settings.leverage).toFixed(1)}% price move against you. Consider 3–5× until you see how the bot performs live.
)}
Min AI confidence
Filter out weak signals. Higher = fewer trades, but only the strongest ones.
{macroOn
? 'When a signal fires: open a BTC long on Hyperliquid → type /adopt in the Telegram bot → bot manages the exit for you.'
: 'Disabled — Macro Vibes alerts sent without bot management. Enable + set up Telegram to activate.'}
updateSettings({ macro_enabled: v })} tone="up" />
{/* Form — only when enabled */}
{macroOn && (() => {
const aggressive = settings.sys2_mode === 'aggressive'
const defLev = aggressive ? 8 : 2
const lev = Math.max(1, Math.min(10, settings.sys2_leverage ?? defLev))
const prot = Math.min(35, 0.85 * 100 / lev)
const liq = 100 / lev
const risky = lev > 2
return (
BTC strategy mode
Standard is calmer. Aggressive uses higher leverage and wider pyramiding.
{aggressive && (
High-risk sleeve. More leverage, harder pyramiding, wider peak-trail. Treat as a separate risk bucket.
)}
BTC bottom leverage
Higher leverage = less room for BTC to drop before the bot starts reducing the position.
{risky
? `⚠️ At ${lev}×, BTC only needs to drop ${prot.toFixed(0)}% before the bot fully closes the position. A normal correction can trigger early exits — use lower leverage unless you're comfortable with that.`
: `At ${lev}×, the bot has room for a ${prot.toFixed(0)}% BTC drop before closing. It reduces the position gradually as the drawdown deepens, so you don't lose everything at once.`}
)
})()}
{/* ── Advanced / Global controls ─────────────────────────────────────── */}
{showAdvanced && (
{/* Daily spend cap */}
Daily trading cap
Daily spending cap — bot stops opening new trades once it hits this amount.
Trading schedule
Daily recurring UTC window — bot only opens new trades within this time range. Overnight windows (e.g. 22:00–02:00) are allowed. Leave off for 24/7.