Repository Public
1/**2 * Order panel of a VENUE account (Hyperliquid testnet or mainnet): the live routes only. It builds the order body with exact3 * decimals (size on the lot, prices on the venue's grid — strings, never a JS number), shows the server's live preview (risk4 * verdict on the venue's REAL state + estimate), and hands the order to the approval sheet: proposal → the canonical order →5 * passkey → the state the server reports. Nothing in this file can send an order without that sheet.6 *7 * Sizes are entered in the base asset only: the chart and the book of the terminal are the venue's MAINNET market data, and a8 * testnet account trades at testnet prices — a USD amount converted at the wrong network's price would be the wrong size.9 */10import { AgentOnAccount } from './LiveAgentNote'; // v7 perp live11import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';12import { useContext, useEffect, useRef, useState, type ReactNode } from 'react';13import { I18n, useT, type Key } from '../i18n';14import { Btn, Field, Icon, inputCls, Notice } from '../ui';15import { RISK_CODES } from './api';16import { Row, Seg, Sheet } from './bits';17import { dpOf, fmt, priceDp, usd } from './dec';18import { ApprovalSheet, LeverageSheet } from './LiveApproval';19import { LiveErrNotice, ModeTag, OrderOutcome, SourceTag } from './LiveBits';20import { buildLive, lapi, LIVE_SLIPPAGE_BPS as SLIPPAGE_BPS, liveError, toneOf, type CancelAll, type LiveConfig, type LiveDetail, type LiveErr, type LiveForm, type LiveOrderBody, type LiveSummary } from './live';21import { refreshLive, type LiveConn } from './liveStore';22import type { Instrument } from './types';2324function useDebounced<T>(v: T, ms: number): T { const [d, setD] = useState(v); useEffect(() => { const h = setTimeout(() => setD(v), ms); return () => clearTimeout(h); }, [v, ms]); return d; }25const num = (s: string) => s.replace(/[^0-9.,\s ]/g, '');2627/** why new orders are stopped, and the ways out — a divergence is explained in plain words, with its two actions */28export function HaltBanner({ account, onDone }: { account: LiveSummary | LiveDetail; onDone?: () => void }) {29 const t = useT(); const qc = useQueryClient();30 const [busy, setBusy] = useState(''), [err, setErr] = useState<LiveErr | null>(null), [ask, setAsk] = useState(false), [note, setNote] = useState('');31 if (!account.halted) return null;32 const run = async (id: string, f: () => Promise<unknown>) => { setBusy(id); setErr(null); setNote(''); try { await f(); await refreshLive(qc, account.id); onDone?.(); } catch (e) { setErr(liveError(e)); } finally { setBusy(''); } };33 return <div className="space-y-2" data-testid="halt-banner" data-kind={account.divergence ? 'divergence' : 'halt'}>34 {account.divergence ? <Notice tone="danger">35 <b className="font-semibold">{t('flDivTitle')}</b> {t('flDivBody')}36 <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{account.divergence}</span>37 <span className="mt-2 block text-[12.5px]">{t('flDivReconcileHint')}</span>38 <span className="mt-2 flex flex-wrap gap-2">39 <button type="button" disabled={!!busy} onClick={() => void run('rec', async () => { const r = await lapi.reconcile(account.id); setNote(t(r.divergence ? 'flDivStill' : 'flDivCleared')); })} className="min-h-11 rounded-xl border border-line bg-surface px-3 text-[13px] font-semibold text-ink disabled:opacity-45">{t('flReconcile')}</button>40 <button type="button" disabled={!!busy} onClick={() => setAsk(true)} className="min-h-11 rounded-xl border border-line bg-surface px-3 text-[13px] font-semibold text-ink disabled:opacity-45">{t('flRebase')}</button></span>41 </Notice> : <Notice tone="danger">42 <b className="font-semibold">{t('flHaltTitle')}</b> {t('flHaltBody')}43 <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{account.halted}</span></Notice>}44 {account.halt && <Btn full kind="soft" busy={busy === 'resume'} onClick={() => void run('resume', () => lapi.resume(account.id))}>{t('flResume')}</Btn>}45 {note && <Notice tone="info">{note}</Notice>}46 {err && <LiveErrNotice err={err} />}47 {ask && <Sheet title={t('flRebaseTitle')} onClose={() => !busy && setAsk(false)}><div className="space-y-3">48 <Notice tone="warn">{t('flRebaseBody')}</Notice>49 <p className="break-words font-mono text-[11.5px] text-mute">{account.divergence}</p>50 <Btn full kind="danger" busy={busy === 'rebase'} onClick={() => void run('rebase', async () => { await lapi.rebase(account.id); setAsk(false); })}>{t('flRebaseConfirm')}</Btn>51 <Btn full kind="ghost" disabled={!!busy} onClick={() => setAsk(false)}>{t('cancel')}</Btn></div></Sheet>}52 </div>;53}5455/** the kill switch: three separate, explicit actions — stop new orders, cancel resting orders, (close positions: in the blotter) */56export function LiveControls({ account, openOrders }: { account: LiveSummary; openOrders: number }) {57 const t = useT(); const qc = useQueryClient();58 const [busy, setBusy] = useState(''), [err, setErr] = useState<LiveErr | null>(null), [ask, setAsk] = useState(false), [res, setRes] = useState<CancelAll | null>(null), [note, setNote] = useState(false);59 const run = async (id: string, f: () => Promise<unknown>) => { setBusy(id); setErr(null); try { await f(); } catch (e) { setErr(liveError(e)); } finally { setBusy(''); void refreshLive(qc, account.id); } };60 const off = account.network === 'mainnet' && !account.mainnetEnabled;61 return <fieldset className="rounded-2xl border border-line p-3" data-testid="live-controls"><legend className="px-1 text-[12.5px] font-semibold text-mute">{t('flControls')}</legend>62 <div className="space-y-2">63 {!account.halt && <Btn full kind="ghost" busy={busy === 'halt'} onClick={() => void run('halt', async () => { await lapi.halt(account.id, 'interface'); setNote(true); })}><Icon n="stop" className="h-4 w-4" />{t('flHalt')}</Btn>}64 {note && account.halt && <p className="text-[12px] leading-snug text-mute">{t('flHaltNote')}</p>}65 <Btn full kind="ghost" disabled={off || !!busy} onClick={() => { setRes(null); setErr(null); setAsk(true); }}><Icon n="x" className="h-4 w-4" />{t('flCancelAll')}{openOrders ? ` (${openOrders})` : ''}</Btn>66 <p className="text-[11.5px] leading-snug text-mute">{t('flControlsHint')}</p>67 {err && !ask && <LiveErrNotice err={err} />}68 </div>69 {ask && <Sheet title={t('flCancelAllTitle')} onClose={() => !busy && setAsk(false)}><div className="space-y-3" data-testid="cancel-all-sheet">70 {!res && <Notice tone="warn">{t('flCancelAllBody')}</Notice>}71 {res && <Notice tone={res.failed.length || (res.remaining?.length ?? 0) > 0 ? 'warn' : 'ok'}><div data-testid="cancel-all-result">{t('flCancelAllDone', { requested: res.requested, accepted: res.accepted, remaining: res.remaining === null ? '?' : res.remaining.length })}72 {res.failed.map((f) => <span key={`${f.venueOrderId}`} className="mt-1 block break-words font-mono text-[11.5px] text-mute">{f.instrument} {f.venueOrderId}: {f.error}</span>)}</div></Notice>}73 {err && <LiveErrNotice err={err} />}74 {!res && <Btn full kind="danger" busy={busy === 'all'} onClick={() => void run('all', async () => setRes(await lapi.cancelAll(account.id)))}>{t('flCancelAllConfirm')}</Btn>}75 <Btn full kind="ghost" disabled={!!busy} onClick={() => setAsk(false)}>{t(res ? 'fxClose' : 'cancel')}</Btn></div></Sheet>}76 </fieldset>;77}7879export function LiveOrderPanel({ instrument, account, detail, conn, workerError, config, switcher, goAccounts }: { instrument: Instrument; account: LiveSummary; detail: LiveDetail | undefined; conn: LiveConn; workerError: string | null;80 config: LiveConfig | undefined; switcher: ReactNode; goAccounts: () => void }) {81 const t = useT(); const { locale } = useContext(I18n);82 const tone = toneOf(account), a = detail ?? account;83 const [f, setF] = useState<LiveForm>({ side: 'buy', kind: 'limit', size: '', price: '', trigger: '', reduceOnly: false, postOnly: false });84 const [review, setReview] = useState<LiveOrderBody | null>(null), [last, setLast] = useState<string | null>(null), [lev, setLev] = useState(false);85 const [levRead, setLevRead] = useState<{ instrument: string; account: string; at: number; v: { leverage: string; marginMode: string } } | null>(null);86 const opening = useRef(false);87 const set = (p: Partial<LiveForm>) => { setF((x) => ({ ...x, ...p })); setLast(null); };88 useEffect(() => { setF((x) => ({ ...x, size: '', price: '', trigger: '' })); setLast(null); }, [instrument.id, account.id]);8990 const pinned = config?.pinnedAssets[account.network] ?? [];91 const mainnetOff = account.network === 'mainnet' && !account.mainnetEnabled;92 const readOnly = a.mode !== 'MANUAL_LIVE', expired = !!a.authorization?.expired && !readOnly;93 const allowed = a.policy.allowedInstruments.includes(instrument.id), isPinned = !config || pinned.some((p) => p.instrument === instrument.id);94 const canTrade = !mainnetOff && !readOnly && !expired && allowed;95 const built = buildLive(f, instrument);96 const latest = useRef(built); latest.current = built;97 const stable = useDebounced(built.body ? JSON.stringify(built.body) : '', 350);98 const pv = useQuery({ queryKey: ['deriv', 'live', 'preview', account.id, stable], enabled: canTrade && !!stable && !review,99 queryFn: () => { const b = latest.current.body; if (!b) throw new Error('no order'); return Promise.race([lapi.preview(account.id, b), new Promise<never>((_, no) => setTimeout(() => no(new Error('timeout')), 12_000))]); },100 refetchInterval: 6000, placeholderData: keepPreviousData, retry: 0, staleTime: 0, gcTime: 0 });101 // a preview is shown only for the order on screen: while the form and the debounced body differ, there is none102 const current = !!built.body && stable === JSON.stringify(built.body);103 const preview = current && !pv.isPlaceholderData ? pv.data : undefined, est = preview?.estimate ?? null;104 const refused = preview && preview.risk.ok === false ? preview.risk : null;105 const pvErr = current && pv.isError && !pv.data ? liveError(pv.error) : null;106 // the venue's own leverage setting for this instrument: as the last preview read it, or as a leverage change read it back since107 const fromPreview = pv.data?.estimate && pv.data.request.instrument === instrument.id ? { leverage: pv.data.estimate.leverage, marginMode: pv.data.estimate.marginMode as string } : null;108 const venueLev = levRead && levRead.instrument === instrument.id && levRead.account === account.id && (!fromPreview || levRead.at >= pv.dataUpdatedAt) ? levRead.v : fromPreview;109 const st = detail?.venueState, pdp = priceDp(instrument.tickSize, est?.mark ?? null), sdp = dpOf(instrument.lotSize);110 const long = f.side === 'buy', marketLike = f.kind !== 'limit';111 const lastOrder = last ? detail?.orders.find((o) => o.clientId === last) : undefined;112 const maxLev = Math.max(1, Math.min(a.authorization?.policy.maxLeverage ?? 1, Math.floor(Number(a.policy.maxLeverage)) || 1, Math.floor(Number(instrument.maxLeverage)) || 1, pinned.find((p) => p.instrument === instrument.id)?.maxLeverage ?? 1000));113 const openReview = () => { if (opening.current || review || !built.body || !preview || refused) return; opening.current = true; setLast(null); setReview(built.body); };114 useEffect(() => { if (!review) opening.current = false; }, [review]);115116 const sideLabel = (s: 'buy' | 'sell') => <span className="inline-flex items-center gap-1.5"><svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden="true"><path d={s === 'buy' ? 'M6 2l4.5 7h-9z' : 'M6 10L1.5 3h9z'} fill="currentColor" /></svg>{t(s === 'buy' ? 'fxLong' : 'fxShort')}</span>;117 const check = (on: boolean, onChange: (v: boolean) => void, label: string, hint?: string) => <label className="flex min-h-11 cursor-pointer items-center gap-2.5 text-[13.5px]"><input type="checkbox" checked={on} onChange={(e) => onChange(e.target.checked)} className="h-5 w-5 shrink-0 accent-[var(--blue)]" /><span>{label}{hint && <span className="block text-[11.5px] text-mute">{hint}</span>}</span></label>;118 const sizeNote = built.body ? `${fmt(built.body.size, { dp: sdp, locale, trim: true })} ${instrument.base}${est ? ` ≈ ${usd(est.notional, locale)}` : ''}` : null;119120 return (121 <div className="space-y-3.5" data-testid="order-panel" data-account-kind="venue" data-network={account.network}>122 <div className="flex items-center justify-between gap-2"><h3 className="text-[15px] font-semibold">{t(tone === 'mainnet' ? 'flOrderReal' : 'flOrderTestnet')}</h3><ModeTag tone={tone} /></div>123 <div className="rounded-2xl bg-elev/70 p-3">124 {switcher}125 {st?.error ? <p className="mt-2 text-[12.5px] leading-snug text-danger" data-testid="venue-unreadable">{t('flUnreadable')} <span className="break-words font-mono text-[11px] text-mute">{st.error}</span></p> : <>126 <div className="mt-1.5 flex items-baseline justify-between text-[12.5px]"><span className="text-mute">{t('fxFreeCollateral')}</span><span className="font-semibold" data-testid="free-collateral">{st?.state ? usd(st.state.freeCollateral, locale) : '—'}</span></div>127 <div className="flex items-baseline justify-between text-[12.5px]"><span className="text-mute">{t('fxEquity')}</span><span className="font-medium">{st?.state ? usd(st.state.equity, locale) : '—'}</span></div>128 <div className="mt-1 flex items-center justify-between gap-2 text-[11px] text-mute"><span className="inline-flex items-center gap-1.5"><SourceTag />{t(conn === 'stream' ? 'flConnStream' : conn === 'polling' ? 'flConnPolling' : 'fxConnecting')}</span></div></>}129 {workerError && <p className="mt-1 break-words text-[11.5px] leading-snug text-warn">{t('flWorkerError')} <span className="font-mono text-[11px]">{workerError}</span></p>}130 </div>131132 {mainnetOff && <Notice tone="warn"><div data-testid="mainnet-off"><b className="font-semibold">{t('flMainnetOffTitle')}</b> {t('flMainnetOff')}</div></Notice>}133 {!mainnetOff && readOnly && <><Notice tone="info"><div data-testid="live-readonly">{t(a.authorization?.revokedAt ? 'flRevokedNote' : 'flReadOnlyNote')}</div></Notice><Btn full kind="soft" onClick={goAccounts}>{t('flGoAuthorize')}</Btn></>}134 {expired && <><Notice tone="warn">{t('flE_authorization_expired')}</Notice><Btn full kind="soft" onClick={goAccounts}>{t('flGoAuthorize')}</Btn></>}135 {!mainnetOff && !readOnly && !allowed && <Notice tone="warn"><div data-testid="live-not-allowed">{t(isPinned ? 'flNotInAuthorization' : 'flNotPinned', { symbol: instrument.symbol })}</div></Notice>}136 <HaltBanner account={a} />137 {/* v7 perp live: an agent trades this account — what a manual order does to it */}138 {a.mandate && a.mandate.phase !== 'ended' && <AgentOnAccount account={a} />}139 {tone === 'testnet' && canTrade && <p className="rounded-xl border border-line px-3 py-2 text-[11.5px] leading-snug text-mute" data-testid="testnet-note">{t('flTestnetPrices')}{est ? <span className="block font-medium text-ink">{t('flTestnetMark', { price: fmt(est.mark, { dp: pdp, locale }) })} <SourceTag /></span> : null}</p>}140141 {canTrade && <>142 <div role="radiogroup" aria-label={t('fxDirection')} className="grid grid-cols-2 gap-2">143 {(['buy', 'sell'] as const).map((s) => <button key={s} type="button" role="radio" aria-checked={f.side === s} onClick={() => set({ side: s })}144 className={`min-h-12 rounded-2xl border text-[15px] font-semibold transition ${f.side === s ? (s === 'buy' ? 'border-emerald bg-emerald text-white' : 'border-danger bg-danger text-white') : 'border-line text-mute hover:text-ink'}`}>{sideLabel(s)}</button>)}145 </div>146 <Seg size="sm" label={t('fxOrderType')} value={f.kind} onChange={(kind) => set({ kind })} options={(['market', 'limit', 'stop', 'tp'] as const).map((k) => ({ id: k, label: t(`fxKindShort_${k}` as Key), aria: t(`fxKind_${k}` as Key) }))} />147 {f.kind === 'limit' && <Field label={`${t('fxLimitPrice')} (${instrument.quote})`}><input className={inputCls} inputMode="decimal" autoComplete="off" value={f.price} onChange={(e) => set({ price: num(e.target.value) })} /></Field>}148 {(f.kind === 'stop' || f.kind === 'tp') && <Field label={`${t('fxTriggerPrice')} (${instrument.quote})`} hint={t(f.kind === 'stop' ? 'fxStopHint' : 'fxTpHint')}><input className={inputCls} inputMode="decimal" autoComplete="off" value={f.trigger} onChange={(e) => set({ trigger: num(e.target.value) })} /></Field>}149 <Field label={`${t('fxSize')} (${instrument.base})`} hint={sizeNote ?? (built.problem ? <span className="text-danger">{t(built.problem)}</span> : t('flSizeHint', { lot: fmt(instrument.lotSize, { locale, trim: true }), base: instrument.base }))}>150 <input className={inputCls} inputMode="decimal" autoComplete="off" aria-label={t('fxSize')} value={f.size} placeholder="0" onChange={(e) => set({ size: num(e.target.value) })} /></Field>151 <div className="flex items-center justify-between gap-3 rounded-2xl border border-line px-3 py-2" data-testid="venue-leverage">152 <div className="min-w-0 text-[13px]"><div className="text-mute">{t('flVenueLeverage')}</div><div className="font-semibold">{venueLev ? `${fmt(venueLev.leverage, { locale, trim: true })}× · ${t(venueLev.marginMode === 'cross' ? 'fxCross' : 'fxIsolated')}` : t('flLevUnknown')}</div></div>153 <button type="button" disabled={!!a.halted} onClick={() => setLev(true)} className="min-h-11 shrink-0 rounded-xl border border-line px-3 text-[13px] font-semibold text-ink disabled:opacity-45">{t('flLevChange')}</button></div>154 <div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2 xl:grid-cols-1">155 {check(f.reduceOnly, (reduceOnly) => set({ reduceOnly }), t('fxReduceOnly'), t('flReduceOnlyHint'))}156 {f.kind === 'limit' && check(f.postOnly, (postOnly) => set({ postOnly }), t('fxPostOnly'), t('fxPostOnlyHint'))}157 </div>158 {marketLike && <p className="text-[11.5px] leading-snug text-mute">{t('flMarketNote', { pct: fmt(String(SLIPPAGE_BPS / 100), { locale, trim: true }) })}</p>}159160 {built.body && <div className="rounded-2xl border border-line p-3" aria-live="polite" data-testid="preview">161 <div className="mb-1 flex items-center justify-between"><span className="text-[12.5px] font-semibold">{t('flPreview')}</span><SourceTag source="estimate" /></div>162 {est ? <>163 <Row label={t('fxNotional')}>{usd(est.notional, locale)}</Row><Row label={t('fxInitialMargin')}>{usd(est.initialMargin, locale)}</Row><Row label={t('flFeeMax')}>{usd(est.fee, locale)}</Row>164 {marketLike && <Row label={t('fxWorstPrice')}>{fmt(preview?.request.price ?? null, { dp: pdp, locale })}</Row>}165 <Row label={<>{t('flVenueMark')} <SourceTag /></>}>{fmt(est.mark, { dp: pdp, locale })}</Row>166 <Row label={t('fxFreeAfter')}>{usd(est.freeCollateralAfter, locale)}</Row>167 <p className="mt-1 text-[11.5px] leading-snug text-mute">{t('flLiqNote')}</p></>168 : <p className="py-2 text-[12.5px] text-mute">{pvErr ? t('fxPreviewError') : t('fxLoading')}</p>}169 </div>}170 {pvErr && <LiveErrNotice err={pvErr} />}171 {refused && <Notice tone="danger"><div data-testid="risk-refusal"><b className="font-semibold">{t('fxRefused')}</b> {RISK_CODES.has(refused.code) && !['halted', 'mode_read_only'].includes(refused.code) ? t(`rk_${refused.code}` as Key) : t(`flE_${refused.code}` as Key)}172 {refused.code === 'order_notional_exceeded' && <span className="mt-1 block">{t('flNotionalSigner')}</span>}173 <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{refused.code} — {refused.reason}</span></div></Notice>}174 {lastOrder && <OrderOutcome order={lastOrder} instrument={instrument} />}175176 <button type="button" data-testid="live-review" disabled={!built.body || !!refused || !preview || !!review || instrument.status === 'halted'} onClick={openReview}177 className={`inline-flex min-h-[50px] w-full items-center justify-center gap-2 rounded-2xl px-5 text-[15.5px] font-semibold text-white transition active:scale-[0.98] disabled:opacity-45 ${long ? 'bg-emerald' : 'bg-danger'}`}>178 {sideLabel(f.side)}<span>· {t('fxReview', { base: instrument.base })}</span></button>179 <p className="text-center text-[11.5px] font-medium leading-snug" data-testid="live-foot"><span className={tone === 'mainnet' ? 'text-danger' : 'text-mute'}>{t(tone === 'mainnet' ? 'flFootReal' : 'flFootTestnet')}</span></p>180 </>}181182 {!readOnly && <LiveControls account={a} openOrders={st?.openOrders.length ?? 0} />}183184 {review && <ApprovalSheet account={a} instrument={instrument} request={{ kind: 'order', body: review }} orders={detail?.orders ?? []} ttlMs={config?.bounds.orderApprovalTtlMs ?? 90_000}185 onClose={(id) => { setReview(null); if (id) { setLast(id); setF((x) => ({ ...x, size: '' })); } }} />}186 {lev && <LeverageSheet account={a} instrument={instrument} max={maxLev} current={venueLev} onRead={(v) => setLevRead({ instrument: instrument.id, account: account.id, at: Date.now(), v })} onClose={() => setLev(false)} />}187 </div>188 );189}190