Repository Public
1/**2 * Accounts of the Futures area: venue status, paper accounts (create, choose, reset, edit the risk limits), a read-only look3 * at any public address on a venue, and controlled live trading (`LiveSection`: venue accounts, authorization, revocation).4 * Nothing in THIS file can send a real order; the live section sends none either without a passkey approval per order.5 */6import { useQuery, useQueryClient } from '@tanstack/react-query';7import { useContext, useState } from 'react';8import { errText, I18n, useT, type Key } from '../i18n';9import { Btn, Card, Field, H, Icon, inputCls, Notice, short, Skeleton } from '../ui';10import { dapi, paperAllowed } from './api';11import { LiveSection } from './LiveAccounts';12import { ageText, DataTable, PaperTag, Pnl, Row, ServerSays, Sheet, SideTag, Stat, VENUE_NAME, VenueBadge, type Col } from './bits';13import { D, dec, fmt, parseInput, pct, priceDp, usd } from './dec';14import type { DerivAccount, Instrument, Position, RiskPolicy, VenueId, VenueInfo, Watch } from './types';1516type LimitKey = 'maxLeverage' | 'maxOrderNotional' | 'maxPositionNotional' | 'maxGrossExposure' | 'minFreeCollateral' | 'maxDailyLoss' | 'maxTotalLoss' | 'minLiquidationDistanceBps' | 'maxDeviationBps' | 'maxMarkIndexBps' | 'maxDataAgeMs' | 'maxOrdersPerMinute' | 'maxOrdersPerDay';17const LIMITS: { k: LimitKey; unit: string; int?: boolean; advanced?: boolean }[] = [18 { k: 'maxLeverage', unit: '×' }, { k: 'maxOrderNotional', unit: 'USD' }, { k: 'maxPositionNotional', unit: 'USD' }, { k: 'maxGrossExposure', unit: 'USD' },19 { k: 'maxDailyLoss', unit: 'USD' }, { k: 'maxTotalLoss', unit: 'USD' }, { k: 'minFreeCollateral', unit: 'USD' }, { k: 'minLiquidationDistanceBps', unit: 'pb' },20 { k: 'maxDeviationBps', unit: 'pb', advanced: true }, { k: 'maxMarkIndexBps', unit: 'pb', advanced: true }, { k: 'maxDataAgeMs', unit: 'ms', int: true, advanced: true },21 { k: 'maxOrdersPerMinute', unit: '/ min', int: true, advanced: true }, { k: 'maxOrdersPerDay', unit: '/ j', int: true, advanced: true },22];23type Limits = Record<LimitKey, string>;24/**25 * The backend's own default policy for a collateral C (services/api/src/derivatives/paper.ts, `defaultPolicy`), shown in the form26 * so that what is created is what was read: leverage 3, order ≤ C, position ≤ 2C, exposure ≤ 3C, free collateral ≥ 0.1C,27 * daily loss 0.05C, total loss 0.2C. Every limit is explained under its field and can be changed.28 */29export function defaultLimits(collateral: string | null): Limits {30 const c = dec(collateral) ?? D.from(10000n), times = (k: string) => D.str(D.mul(c, D.from(k)));31 return { maxLeverage: '3', maxOrderNotional: times('1'), maxPositionNotional: times('2'), maxGrossExposure: times('3'), minFreeCollateral: times('0.1'), maxDailyLoss: times('0.05'), maxTotalLoss: times('0.2'),32 minLiquidationDistanceBps: '1000', maxDeviationBps: '200', maxMarkIndexBps: '100', maxDataAgeMs: '10000', maxOrdersPerMinute: '10', maxOrdersPerDay: '200' };33}34const defaultAllowed = (venue: string, list: Instrument[]) => [`${venue}:BTC`, `${venue}:ETH`].filter((id) => !list.length || list.some((i) => i.id === id));35const limitsOf = (p: RiskPolicy): Limits => Object.fromEntries(LIMITS.map(({ k }) => [k, String(p[k])])) as Limits;36function toPolicy(l: Limits): Partial<RiskPolicy> | null {37 const out: Record<string, string | number> = {};38 for (const { k, int } of LIMITS) {39 const v = parseInput(l[k]); if (v === null) return null;40 if (int) { if (!/^\d+$/.test(v)) return null; out[k] = Number(v); } else out[k] = v;41 }42 if (!/[1-9]/.test(String(out.maxLeverage))) return null;43 return out as Partial<RiskPolicy>;44}4546/** markets the account may trade: anything not listed is refused by the risk engine (`instrument_not_allowed`) */47function AllowedEditor({ value, onChange, options }: { value: string[]; onChange: (v: string[]) => void; options: Instrument[] }) {48 const t = useT(); const sym = (id: string) => id.slice(id.indexOf(':') + 1);49 const rest = options.filter((i) => !value.includes(i.id));50 return <div data-testid="allowed-editor">51 <div className="mb-1.5 text-[13px] text-mute">{t('fxAllowed')}</div>52 <div className="flex flex-wrap items-center gap-2">53 {value.map((id) => <span key={id} className="inline-flex items-center gap-1 rounded-full border border-line bg-surface pl-3 text-[13.5px] font-medium">{sym(id)}54 <button type="button" onClick={() => onChange(value.filter((x) => x !== id))} aria-label={t('fxAllowedRemove', { symbol: sym(id) })} className="grid h-11 w-9 place-items-center text-mute"><Icon n="x" className="h-3.5 w-3.5" /></button></span>)}55 {rest.length > 0 && <select aria-label={t('fxAllowedAdd')} value="" onChange={(e) => { if (e.target.value) onChange([...value, e.target.value]); }} className="min-h-11 rounded-full border border-line bg-surface px-3 text-[13.5px] text-ink">56 <option value="">{t('fxAllowedAdd')}</option>{rest.map((i) => <option key={i.id} value={i.id}>{i.symbol}</option>)}</select>}57 </div>58 <p className="mt-1.5 text-[12px] leading-snug text-mute">{value.length ? t('fxAllowedHint') : <span className="text-danger">{t('fxAllowedNone')}</span>}</p>59 </div>;60}61function LimitsForm({ value, onChange, allowed, onAllowed, options }: { value: Limits; onChange: (l: Limits) => void; allowed: string[]; onAllowed: (v: string[]) => void; options: Instrument[] }) {62 const t = useT(); const [more, setMore] = useState(false);63 return <div className="space-y-3">64 <AllowedEditor value={allowed} onChange={onAllowed} options={options} />65 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">{LIMITS.filter((x) => more || !x.advanced).map(({ k, unit }) => <Field key={k} label={t(`fxLim_${k}` as Key)} hint={t(`fxLimHint_${k}` as Key)}>66 <div className="flex items-center gap-2"><input className={inputCls} inputMode="decimal" autoComplete="off" value={value[k]} onChange={(e) => onChange({ ...value, [k]: e.target.value.replace(/[^0-9.,]/g, '') })} /><span className="w-10 shrink-0 text-[12.5px] text-mute">{unit === 'pb' ? t('fxBps') : unit === '/ j' ? t('fxPerDay') : unit}</span></div></Field>)}</div>67 <button type="button" onClick={() => setMore(!more)} aria-expanded={more} className="min-h-11 text-[13.5px] font-medium text-blue">{t(more ? 'fxLimLess' : 'fxLimMore')}</button>68 </div>;69}7071const STATUS: Record<string, [string, string, Key]> = { ok: ['text-emerald bg-emerald/10', 'check', 'fxVenue_ok'], degraded: ['text-warn bg-warn/12', 'warn', 'fxVenue_degraded'], down: ['text-danger bg-danger/10', 'x', 'fxVenue_down'] };72export function VenuePill({ v }: { v: VenueInfo }) {73 const t = useT(); const [cls, ic, key] = STATUS[v.status] ?? STATUS.down!;74 return <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-semibold ${cls}`}><Icon n={ic} className="h-3 w-3" />{t(key)}</span>;75}76/** one line per venue: status in words, and what the venue is limited to here */77export function VenueStrip({ venues, compact }: { venues: VenueInfo[]; compact?: boolean }) {78 const t = useT();79 if (compact) return <ul className="no-scrollbar -mx-5 flex gap-2 overflow-x-auto px-5 lg:mx-0 lg:px-0" aria-label={t('fxVenues')} data-testid="venue-strip">{venues.map((v) => <li key={v.id} className="flex shrink-0 items-center gap-2 rounded-full border border-line bg-surface py-1 pl-2.5 pr-1.5 text-[12px]">80 <span className="font-semibold">{VENUE_NAME[v.id] ?? v.name}</span><VenuePill v={v} />{(v.restriction || !paperAllowed(v)) && <span className="pr-1.5 text-mute">{t(v.id === 'dydx' || v.restriction ? 'fxDataOnly' : 'fxReadOnly')}</span>}</li>)}</ul>;81 return <ul className="divide-y divide-line" data-testid="venue-list">{venues.map((v) => <li key={v.id} className="py-3 first:pt-0 last:pb-0">82 <div className="flex flex-wrap items-center gap-2"><VenueBadge venue={v.id} /><VenuePill v={v} /><span className="text-[12px] text-mute">{v.network}{v.lastOkAt ? ` · ${t('fxLastOk', { age: ageText(Date.now() - v.lastOkAt) })}` : ''}</span></div>83 <p className="mt-1.5 text-[13px] leading-snug">{t(`fxVenueWhat_${v.id}` as Key)}</p>84 {v.restriction && <ServerSays text={v.restriction} className="mt-1 text-[12px] leading-snug text-mute" />}</li>)}</ul>;85}8687export function AccountsView({ venues, instruments, accounts, loading, selected, onSelect }: { venues: VenueInfo[]; instruments: Instrument[]; accounts: DerivAccount[]; loading: boolean; selected: string | null; onSelect: (id: string) => void }) {88 const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();89 const eligible = venues.filter(paperAllowed);90 const [open, setOpen] = useState(false), [busy, setBusy] = useState(''), [err, setErr] = useState('');91 // `limits` / `allowed` stay null until the owner edits them: until then they follow the collateral and the venue (the backend's defaults)92 const [f, setF] = useState<{ venue: VenueId | ''; label: string; collateral: string; limits: Limits | null; allowed: string[] | null }>({ venue: '', label: '', collateral: '10000', limits: null, allowed: null });93 const [edit, setEdit] = useState<{ a: DerivAccount; limits: Limits; allowed: string[] } | null>(null), [reset, setReset] = useState<DerivAccount | null>(null);94 const venue = f.venue || eligible[0]?.id || '';95 const ofVenue = (v: string) => instruments.filter((i) => i.venue === v && i.status !== 'halted');96 const limits = f.limits ?? defaultLimits(parseInput(f.collateral)), allowed = f.allowed ?? defaultAllowed(venue, ofVenue(venue));97 const refresh = () => Promise.all([qc.invalidateQueries({ queryKey: ['deriv', 'accounts'] }), qc.invalidateQueries({ queryKey: ['deriv', 'account'] })]);98 const act = async (id: string, fn: () => Promise<unknown>, after?: () => void) => { setBusy(id); setErr(''); try { await fn(); await refresh(); after?.(); } catch (e) { setErr(errText(t, e) || t('fxErrGeneric')); } finally { setBusy(''); } };99 const collateral = parseInput(f.collateral), policy = toPolicy(limits);100 const collateralOk = !!collateral && D.gte(D.from(collateral), D.from(10n)) && D.lte(D.from(collateral), D.from(1_000_000_000n));101 const create = () => act('create', async () => { const a = await dapi.createAccount({ venue: venue as VenueId, label: f.label.trim() || t('fxDefaultLabel', { venue: VENUE_NAME[venue as VenueId] }), collateral: collateral!, policy: { ...policy!, allowedInstruments: allowed } }); onSelect(a.id); }, () => { setOpen(false); setF({ ...f, label: '' }); });102103 return (104 <div className="space-y-4" data-testid="accounts-view">105 <Card><H>{t('fxVenues')}</H>{venues.length ? <VenueStrip venues={venues} /> : <Skeleton h="h-24" />}</Card>106107 <Card>108 <H right={<Btn kind="soft" onClick={() => { setOpen(!open); setErr(''); }} disabled={!eligible.length}><Icon n={open ? 'x' : 'plus'} className="h-4 w-4" />{open ? t('cancel') : t('fxNewAccount')}</Btn>}>{t('fxPaperAccounts')}</H>109 <p className="mb-3 text-[13px] leading-snug text-mute">{t('fxPaperIntro')}</p>110 {open && <div className="mb-4 space-y-3 rounded-2xl border border-line p-4" data-testid="account-form">111 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">112 <Field label={t('fxVenue')} hint={t('fxVenueHint')}><select className={inputCls} value={venue} onChange={(e) => setF({ ...f, venue: e.target.value as VenueId, allowed: null })}>113 {venues.map((v) => <option key={v.id} value={v.id} disabled={!paperAllowed(v)}>{VENUE_NAME[v.id] ?? v.name}{paperAllowed(v) ? '' : ` — ${t('fxNotAvailable')}`}</option>)}</select></Field>114 <Field label={t('fxLabel')}><input className={inputCls} maxLength={40} value={f.label} placeholder={venue ? t('fxDefaultLabel', { venue: VENUE_NAME[venue as VenueId] }) : ''} onChange={(e) => setF({ ...f, label: e.target.value })} /></Field>115 <Field label={t('fxStartCollateral')} hint={collateral && !collateralOk ? <span className="text-danger">{t('fxCollateralRange')}</span> : t('fxStartCollateralHint')}><div className="flex items-center gap-2"><input className={inputCls} inputMode="decimal" value={f.collateral} onChange={(e) => setF({ ...f, collateral: e.target.value.replace(/[^0-9.,]/g, '') })} /><span className="w-12 shrink-0 text-[12.5px] text-mute">USDC</span></div></Field>116 </div>117 <h3 className="pt-1 text-[14px] font-semibold">{t('fxLimits')}</h3>118 <p className="text-[12.5px] leading-snug text-mute">{t('fxLimitsIntro')}</p>119 <LimitsForm value={limits} onChange={(l) => setF({ ...f, limits: l })} allowed={allowed} onAllowed={(a) => setF({ ...f, allowed: a })} options={ofVenue(venue)} />120 {f.limits && <button type="button" className="min-h-11 text-[13.5px] font-medium text-blue" onClick={() => setF({ ...f, limits: null })}>{t('fxLimitsDefault')}</button>}121 {err && <Notice tone="danger">{err}</Notice>}122 <Btn full busy={busy === 'create'} disabled={!venue || !collateralOk || !policy || !allowed.length} onClick={() => void create()}>{t('fxCreatePaper')}</Btn>123 </div>}124 {loading ? <Skeleton h="h-24" /> : accounts.length === 0 ? !open && <p className="py-6 text-center text-[13.5px] text-mute">{t('fxNoAccounts')}</p> :125 <ul className="space-y-3">{[...accounts].sort((x, y) => Number(y.id === selected) - Number(x.id === selected)).map((a) => { const on = a.id === selected; return <li key={a.id} className={`rounded-2xl border p-4 ${on ? 'border-ink' : 'border-line'}`}>126 <div className="flex flex-wrap items-center gap-2"><span className="text-[15.5px] font-semibold">{a.label}</span><VenueBadge venue={a.venue} />{a.kind === 'paper' ? <PaperTag /> : <span className="rounded-md border border-line px-1.5 py-px text-[10.5px] font-bold uppercase text-mute">{t('fxReadOnly')}</span>}127 {on && <span className="inline-flex items-center gap-1 text-[12px] font-semibold text-ink"><Icon n="check" className="h-3.5 w-3.5" />{t('fxSelected')}</span>}</div>128 <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">129 <Stat label={t('fxEquity')}>{usd(a.state.equity, locale)}</Stat><Stat label={t('fxFreeCollateral')}>{usd(a.state.freeCollateral, locale)}</Stat>130 <Stat label={t('fxMarginUsed')}>{usd(a.state.marginUsed, locale)}</Stat><Stat label={t('fxPositions')}>{a.positions.length} · {a.openOrders.length} {t('fxOrdersSmall')}</Stat></div>131 {a.kind === 'paper' && <p className="mt-2 text-[12px] leading-snug text-mute">{t('fxPaperSimpl', { mm: a.settings ? pct(a.settings.maintenanceMarginFraction, 1, locale) : '2 %', taker: a.settings ? pct(a.settings.takerFeeRate, 3, locale) : '—' })} {t('fxAllowedNow', { list: a.policy.allowedInstruments.map((x) => x.slice(x.indexOf(':') + 1)).join(', ') || '—' })}</p>}132 {a.policy.halt && <div className="mt-3"><Notice tone="danger">{t('fxHalted')} <span className="font-mono text-[12px]">{a.policy.halt}</span>133 <button type="button" className="mt-1 block min-h-11 font-semibold underline" disabled={!!busy} onClick={() => void act(`h${a.id}`, () => dapi.setPolicy(a.id, { ...a.policy, halt: null }))}>{t('fxLiftHalt')}</button></Notice></div>}134 {a.kind === 'paper' && <div className="mt-3 flex flex-wrap gap-2">135 {!on && <Btn kind="soft" onClick={() => onSelect(a.id)}>{t('fxUse')}</Btn>}136 <Btn kind="ghost" onClick={() => { setErr(''); setEdit({ a, limits: limitsOf(a.policy), allowed: a.policy.allowedInstruments }); }}>{t('fxEditLimits')}</Btn>137 <Btn kind="ghost" onClick={() => { setErr(''); setReset(a); }}>{t('fxReset')}</Btn></div>}138 </li>; })}</ul>}139 </Card>140141 <WatchCard venues={venues} />142143 <LiveSection selected={selected} onSelect={onSelect} />144145 {edit && <Sheet wide title={t('fxEditLimitsTitle', { label: edit.a.label })} onClose={() => !busy && setEdit(null)}><div className="space-y-3">146 <p className="text-[12.5px] leading-snug text-mute">{t('fxLimitsIntro')}</p>147 <LimitsForm value={edit.limits} onChange={(l) => setEdit({ ...edit, limits: l })} allowed={edit.allowed} onAllowed={(a) => setEdit({ ...edit, allowed: a })} options={ofVenue(edit.a.venue)} />148 {err && <Notice tone="danger">{err}</Notice>}149 <Btn full busy={busy === 'policy'} disabled={!toPolicy(edit.limits) || !edit.allowed.length} onClick={() => void act('policy', () => dapi.setPolicy(edit.a.id, { ...edit.a.policy, ...toPolicy(edit.limits)!, allowedInstruments: edit.allowed }), () => setEdit(null))}>{t('fxSaveLimits')}</Btn></div></Sheet>}150 {reset && <Sheet title={t('fxResetTitle', { label: reset.label })} onClose={() => !busy && setReset(null)}><div className="space-y-3">151 <Notice tone="warn">{t('fxResetBody')}</Notice>{err && <Notice tone="danger">{err}</Notice>}152 <Btn full kind="danger" busy={busy === 'reset'} onClick={() => void act('reset', () => dapi.reset(reset.id), () => setReset(null))}>{t('fxResetConfirm')}</Btn>153 <Btn full kind="ghost" disabled={!!busy} onClick={() => setReset(null)}>{t('cancel')}</Btn></div></Sheet>}154 </div>155 );156}157158function WatchCard({ venues }: { venues: VenueInfo[] }) {159 const t = useT(); const { locale } = useContext(I18n);160 const can = venues.filter((v) => v.capabilities?.account && !v.restriction);161 const [venue, setVenue] = useState<VenueId | ''>(''), [addr, setAddr] = useState(''), [asked, setAsked] = useState<{ venue: VenueId; addr: string } | null>(null);162 const v = venue || can[0]?.id || '';163 const q = useQuery({ queryKey: ['deriv', 'watch', asked?.venue, asked?.addr], enabled: !!asked, queryFn: () => dapi.watch(asked!.addr, asked!.venue), refetchInterval: 15_000, retry: 0 });164 const w: Watch | undefined = asked ? q.data : undefined;165 const cols: Col<Position>[] = [166 { id: 'm', label: t('fxMarket'), head: true, cell: (p) => p.instrument.slice(p.instrument.indexOf(':') + 1) }, { id: 's', label: t('fxDirection'), head: true, cell: (p) => <SideTag side={p.side} /> },167 { id: 'size', label: t('fxSize'), num: true, cell: (p) => fmt(p.size, { locale, trim: true }) }, { id: 'e', label: t('fxEntry'), num: true, cell: (p) => fmt(p.entryPrice, { dp: priceDp('0.000001', p.entryPrice), locale }) },168 { id: 'mk', label: t('fxMark'), num: true, cell: (p) => fmt(p.markPrice, { dp: priceDp('0.000001', p.markPrice), locale }) }, { id: 'l', label: t('fxLiqShort'), num: true, cell: (p) => (p.liquidationPrice ? fmt(p.liquidationPrice, { dp: priceDp('0.000001', p.liquidationPrice), locale }) : '—') },169 { id: 'lev', label: t('fxLeverage'), num: true, cell: (p) => `${fmt(p.leverage, { dp: 1, locale, trim: true })}×` }, { id: 'u', label: t('fxUnrealized'), num: true, cell: (p) => <Pnl value={p.unrealizedPnl} locale={locale} /> },170 ];171 return <Card><div data-testid="watch-card">172 <H>{t('fxWatch')}</H>173 <p className="mb-3 text-[13px] leading-snug text-mute">{t('fxWatchIntro')}</p>174 <form className="grid grid-cols-1 gap-3 sm:grid-cols-[180px_1fr_auto] sm:items-end" onSubmit={(e) => { e.preventDefault(); if (v && addr.trim()) setAsked({ venue: v as VenueId, addr: addr.trim() }); }}>175 <Field label={t('fxVenue')}><select className={inputCls} value={v} onChange={(e) => setVenue(e.target.value as VenueId)}>{venues.map((x) => <option key={x.id} value={x.id} disabled={!can.includes(x)}>{VENUE_NAME[x.id] ?? x.name}{can.includes(x) ? '' : ` — ${t('fxNotAvailable')}`}</option>)}</select></Field>176 <Field label={t('fxAddress')}><input className={`${inputCls} font-mono text-[14px]`} value={addr} onChange={(e) => setAddr(e.target.value)} placeholder="0x…" autoCapitalize="off" autoCorrect="off" spellCheck={false} /></Field>177 <Btn type="submit" kind="soft" busy={q.isFetching && !q.data} disabled={!v || !addr.trim()}><Icon n="eye" className="h-4 w-4" />{t('fxWatchGo')}</Btn>178 </form>179 {asked && q.isError && <div className="mt-3"><Notice tone="danger">{errText(t, q.error) || t('fxErrGeneric')}</Notice></div>}180 {w && asked && <div className="mt-4 space-y-3" data-testid="watch-result">181 <div className="flex flex-wrap items-center gap-2 text-[12.5px]"><VenueBadge venue={asked.venue} /><span className="font-mono">{short(asked.addr)}</span><span className="rounded-md border border-line px-1.5 py-px text-[10.5px] font-bold uppercase text-mute">{t('fxReadOnly')}</span><span className="text-mute">{t('fxSourceVenue')}</span></div>182 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"><Stat label={t('fxEquity')}>{usd(w.state?.equity, locale)}</Stat><Stat label={t('fxFreeCollateral')}>{usd(w.state?.freeCollateral, locale)}</Stat><Stat label={t('fxMarginUsed')}>{usd(w.state?.marginUsed, locale)}</Stat><Stat label={t('fxWithdrawable')}>{usd(w.state?.withdrawable, locale)}</Stat></div>183 <DataTable label={t('fxPositions')} cols={cols} rows={w.positions} rowKey={(p) => p.instrument} empty={t('fxNoPositions')} />184 {w.openOrders.length > 0 && <Row label={t('fxOpenOrders')}>{w.openOrders.length}</Row>}185 </div>}</div>186 </Card>;187}188