SPB Git

Repository Public

LiveApproval.tsx 17.4 KB 201 lines tsx

1/**2 * The two passkey-approved actions of a venue account, each in its own sheet:3 *   order / close :  proposal (POST …/orders or …/close, with ONE Idempotency-Key per review) → the canonical order shown field4 *                    by field + estimate + countdown → passkey → approve → the order's state, from the server;5 *   leverage      :  proposal → canonical → passkey → approve → the venue's own setting read back.6 *7 * Safety of the screen itself:8 *  - the proposal is made once per key (a double click or a re-render cannot make a second one); "prepare again" takes a new key;9 *  - the approve button exists only while the proposal is approvable: not expired (3 s margin), and equal to what was asked;10 *  - a cancelled or failed passkey prompt, an expiry, or closing the sheet WITHDRAWS the proposal on the server (no passkey11 *    needed), so nothing approvable is left behind; the only thing ever re-sent as is, is an approval whose answer was lost;12 *  - `unknown` is shown as being checked on the venue, with no button that could send the order again.13 */14import { startAuthentication } from '@simplewebauthn/browser';15import { useQueryClient } from '@tanstack/react-query';16import { useContext, useEffect, useRef, useState } from 'react';17import { I18n, useT, type Key } from '../i18n';18import { Btn, Icon, Notice } from '../ui';19import { Row, Sheet } from './bits';20import { fmt, usd } from './dec';21import { CanonicalRows, Countdown, LiveErrNotice, ModeBand, OrderOutcome, SourceTag } from './LiveBits';22import { canonicalProblem, closeProblem, inFlight, lapi, liveError, newKey, remainingMs, toneOf, type LeverageProposal, type LeverageResult, type LiveErr, type LiveOrder, type LiveOrderBody, type LiveSummary, type Proposal } from './live';23import { refreshLive } from './liveStore';24import type { Instrument, Position } from './types';2526export type ApprovalRequest = { kind: 'order'; body: LiveOrderBody } | { kind: 'close'; position: Position; fraction: string };27type Phase = 'proposing' | 'ready' | 'passkey' | 'sending' | 'resend' | 'done' | 'void';28const local = (key: string, detail = ''): LiveErr => ({ key, code: key, status: 0, detail, consumed: true, retry: false });2930export function ApprovalSheet({ account, instrument, request, orders, ttlMs, onClose }: { account: LiveSummary; instrument: Instrument | undefined; request: ApprovalRequest; /** the account's orders, kept current by the stream */ orders: LiveOrder[];31  ttlMs: number; onClose: (clientId: string | null) => void }) {32  const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();33  const tone = toneOf(account);34  const [key, setKey] = useState(() => newKey(request.kind));35  const [phase, setPhase] = useState<Phase>('proposing'), [p, setP] = useState<Proposal | null>(null), [skew, setSkew] = useState(0);36  const [err, setErr] = useState<LiveErr | null>(null), [sent, setSent] = useState<LiveOrder | null>(null);37  const started = useRef<string | null>(null), assertion = useRef<unknown>(null), phaseRef = useRef<Phase>('proposing');38  phaseRef.current = phase;39  const lock = useRef(false);40  /**41   * Nothing approvable stays on the server once the screen has given up on a proposal. The server is asked first what the order42   * is: only a proposal that was never approved is withdrawn (a local row, nothing was signed) — an order that reached the43   * venue is never cancelled from here.44   */45  const withdraw = (k: string) => { void (async () => {46    try { const d = await lapi.account(account.id), o = d.orders.find((x) => x.clientId === k); if (o && (o.state === 'proposed' || o.state === 'risk_checked')) await lapi.cancel(account.id, k); } catch { /* it expires by itself after 90 s */ }47    void refreshLive(qc, account.id);48  })(); };49  const fail = (e: LiveErr, k = key) => { setErr(e); setPhase('void'); withdraw(k); };5051  const propose = async (k: string) => {52    setPhase('proposing'); setErr(null); setP(null); assertion.current = null;53    try {54      const r = request.kind === 'order' ? await lapi.propose(account.id, request.body, k) : await lapi.close(account.id, request.position.instrument, request.fraction, k);55      setP(r);56      if (!r.options) { setSent(r.order); setPhase('done'); return; }             // an answer for a key already used: show what that order became57      const why = request.kind === 'order' ? canonicalProblem(request.body, r.canonical) : closeProblem(request.position, r.canonical);58      if (why) { fail(local('canonical_mismatch', why), k); return; }59      setSkew(r.idempotent ? 0 : Date.now() - r.order.updatedAt);60      if (remainingMs(r.expiresAt, Date.now(), r.idempotent ? 0 : Date.now() - r.order.updatedAt) <= 0) { fail(local('proposal_expired'), k); return; }61      setPhase('ready');62    } catch (e) {63      const le = liveError(e, 'propose');64      setErr(le); setPhase(le.retry ? 'resend' : 'void');65    } finally { void refreshLive(qc, account.id); }66  };67  useEffect(() => { if (started.current === key) return; started.current = key; void propose(key); }, [key]);   // eslint-disable-line react-hooks/exhaustive-deps6869  const send = async () => {70    setPhase('sending'); setErr(null);71    try { const r = await lapi.approveOrder(account.id, key, assertion.current); setSent(r.order); setPhase('done'); }72    catch (e) { const le = liveError(e, 'approve'); if (le.retry) { setErr(le); setPhase('resend'); } else fail(le); }73    finally { void refreshLive(qc, account.id); }74  };75  const approve = async () => {76    if (lock.current || phaseRef.current !== 'ready' || !p?.options) return;     // one prompt, one approval, however fast the button is pressed77    lock.current = true;78    try {79      setPhase('passkey'); setErr(null);80      let a: unknown;81      try { a = await startAuthentication({ optionsJSON: p.options }); }82      catch (e) { fail(liveError(e, 'approve')); return; }83      if (remainingMs(p.expiresAt, Date.now(), skew) <= 0) { fail(local('proposal_expired')); return; }   // the prompt was answered too late: nothing is sent84      assertion.current = a;85      await send();86    } finally { lock.current = false; }87  };88  const again = () => { setSent(null); setKey(newKey(request.kind)); };89  const close = () => {90    if (phase === 'sending' || phase === 'passkey') return;                        // an approval is on its way: its answer must be seen91    if (phase === 'proposing' || phase === 'ready' || (phase === 'resend' && !assertion.current)) withdraw(key);92    onClose(sent || phase === 'resend' ? key : null);93  };9495  const current = orders.find((o) => o.clientId === key), order = current && (!sent || current.updatedAt >= sent.updatedAt) ? current : sent;96  const c = p?.canonical ?? null, est = p?.estimate ?? null;97  const title = t(request.kind === 'close' ? 'flCloseTitle' : 'flApproveTitle');98  const passkeyLabel = t(tone === 'mainnet' ? 'flApproveReal' : 'flApproveTestnet');99  return <Sheet title={title} onClose={close}>100    <div className="space-y-3" data-testid="approval-sheet" data-phase={phase}>101      <ModeBand tone={tone} label={account.label} />102      {phase === 'proposing' && <p className="flex items-center gap-2 py-6 text-[13.5px] text-mute"><Icon n="spin" className="h-4 w-4" />{t('flProposing')}</p>}103104      {c && phase !== 'proposing' && phase !== 'done' && <>105        <h3 className="text-[13px] font-semibold">{t('flSignedExactly')}</h3>106        <div className={`rounded-2xl bg-elev/70 p-3 ${phase === 'void' ? 'opacity-50' : ''}`}><CanonicalRows c={c} instrument={instrument} /></div>107        {request.kind === 'close' && p?.slippageBoundBps ? <p className="text-[12px] leading-snug text-mute">{t('flCloseBound', { bps: p.slippageBoundBps, pct: fmt(String(p.slippageBoundBps / 100), { locale, trim: true }) })}</p> : null}108        {est && <div className={`rounded-2xl border border-line p-3 ${phase === 'void' ? 'opacity-50' : ''}`}>109          <div className="mb-1 flex items-center justify-between"><span className="text-[12.5px] font-semibold">{t('flEstimate')}</span><SourceTag source="estimate" /></div>110          <Row label={t('fxNotional')}>{usd(est.notional, locale)}</Row><Row label={t('fxInitialMargin')}>{usd(est.initialMargin, locale)}</Row>111          <Row label={t('flFeeMax')}>{usd(est.fee, locale)}</Row><Row label={t('fxFreeAfter')}>{usd(est.freeCollateralAfter, locale)}</Row>112          <Row label={t('flVenueLeverage')}>{fmt(est.leverage, { locale, trim: true })}× · {t(est.marginMode === 'cross' ? 'fxCross' : 'fxIsolated')}</Row>113          <p className="mt-1 text-[11.5px] leading-snug text-mute">{t('flLiqNote')}</p></div>}114        {p?.hash && <p className="break-all font-mono text-[10.5px] leading-snug text-mute">{t('fingerprint')} SHA-256 {p.hash}</p>}115      </>}116117      {phase === 'ready' && p?.expiresAt && <Countdown expiresAt={p.expiresAt} skewMs={skew} ttlMs={ttlMs} onExpire={() => { if (phaseRef.current === 'ready') fail(local('proposal_expired')); }} />}118      {phase === 'passkey' && <Notice tone="info">{t('flPasskeyWaiting')}</Notice>}119      {phase === 'sending' && <p className="flex items-center gap-2 text-[13.5px] text-mute"><Icon n="spin" className="h-4 w-4" />{t('flSending')}</p>}120      {err && <LiveErrNotice err={err} tone={err.key === 'passkey_cancelled' ? 'warn' : 'danger'}>{phase === 'void' && <span className="mt-1 block">{t('flVoidNote')}</span>}</LiveErrNotice>}121      {phase === 'done' && order && <><OrderOutcome order={order} instrument={instrument} />122        {inFlight(order.state) && <Notice tone="warn">{t('flUnknownNote')}</Notice>}123        {order.canonical && <details className="rounded-2xl border border-line p-3 text-[13px]"><summary className="cursor-pointer text-[12.5px] font-semibold">{t('flWhatWasSigned')}</summary><div className="mt-2"><CanonicalRows c={order.canonical} instrument={instrument} /></div></details>}</>}124125      {phase === 'ready' && <Btn full kind={tone === 'mainnet' ? 'danger' : 'primary'} onClick={() => void approve()}><Icon n="key" className="h-4 w-4" />{passkeyLabel}</Btn>}126      {phase === 'resend' && (assertion.current127        ? <Btn full onClick={() => void send()}>{t('flResendApproval')}</Btn>128        : <Btn full onClick={() => void propose(key)}>{t('retry')}</Btn>)}129      {phase === 'void' && <Btn full kind="soft" onClick={again} data-testid="prepare-again">{t('flPrepareAgain')}</Btn>}130      <Btn full kind="ghost" disabled={phase === 'sending' || phase === 'passkey'} onClick={close}>{t(phase === 'done' ? 'fxClose' : 'cancel')}</Btn>131      {(phase === 'ready' || phase === 'proposing') && <p className="text-center text-[11.5px] leading-snug text-mute">{t('flCancelWithdraws')}</p>}132    </div>133  </Sheet>;134}135136/** leverage is a setting of the VENUE, per instrument: changing it is a signed action, approved with the passkey */137export function LeverageSheet({ account, instrument, max, current, onRead, onClose }: { account: LiveSummary; instrument: Instrument; max: number; current: { leverage: string; marginMode: string } | null;138  /** the venue's own setting, each time the server reads it back */ onRead: (v: { leverage: string; marginMode: string }) => void; onClose: () => void }) {139  const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();140  const tone = toneOf(account);141  const [lev, setLev] = useState(() => Math.max(1, Math.min(max, Math.round(Number(current?.leverage ?? 1)) || 1))), [mode, setMode] = useState<'cross' | 'isolated'>(current?.marginMode === 'isolated' ? 'isolated' : 'cross');142  const [phase, setPhase] = useState<'form' | 'proposing' | 'ready' | 'passkey' | 'sending' | 'done' | 'void'>('form');143  const [p, setP] = useState<LeverageProposal | null>(null), [skew, setSkew] = useState(0), [err, setErr] = useState<LiveErr | null>(null), [res, setRes] = useState<LeverageResult | null>(null);144  const phaseRef = useRef(phase); phaseRef.current = phase;145  const lock = useRef(false);146  const busy = phase === 'proposing' || phase === 'passkey' || phase === 'sending';147  const once = async (f: () => Promise<void>) => { if (lock.current) return; lock.current = true; try { await f(); } finally { lock.current = false; } };148  const propose = () => once(async () => {149    if (phaseRef.current !== 'form' && phaseRef.current !== 'void') return;150    setPhase('proposing'); setErr(null);151    try { const r = await lapi.leverage(account.id, { instrument: instrument.id, leverage: lev, marginMode: mode });152      if (r.canonical.instrument !== instrument.id || r.canonical.leverage !== lev || r.canonical.marginMode !== mode) { setErr(local('canonical_mismatch')); setPhase('void'); return; }153      if (r.current) onRead(r.current);154      setP(r); setSkew(Date.now() - r.action.createdAt); setPhase('ready'); }155    catch (e) { setErr(liveError(e, 'propose')); setPhase('void'); }156  });157  const approve = () => once(async () => {158    if (phaseRef.current !== 'ready' || !p) return;159    setPhase('passkey'); setErr(null);160    let a: unknown;161    try { a = await startAuthentication({ optionsJSON: p.options }); } catch (e) { setErr(liveError(e, 'approve')); setPhase('void'); return; }162    if (remainingMs(p.expiresAt, Date.now(), skew) <= 0) { setErr(local('proposal_expired')); setPhase('void'); return; }163    setPhase('sending');164    try { const r = await lapi.approveLeverage(account.id, p.action.id, a); if (r.venue?.leverage) onRead(r.venue.leverage); setRes(r); setPhase('done'); }165    catch (e) { setErr(liveError(e, 'approve')); setPhase('void'); }166    finally { void refreshLive(qc, account.id); void qc.invalidateQueries({ queryKey: ['deriv', 'live', 'preview'] }); }167  });168  const state = res?.action.state;169  return <Sheet title={t('flLevTitle', { symbol: instrument.symbol })} onClose={() => { if (!busy) onClose(); }}>170    <div className="space-y-3" data-testid="leverage-sheet" data-phase={phase}>171      <ModeBand tone={tone} label={account.label} />172      <p className="text-[13px] leading-snug text-mute">{t('flLevIntro')}</p>173      {(phase === 'form' || phase === 'proposing' || phase === 'void') && <>174        <div><div className="mb-1 flex items-baseline justify-between text-[13px]"><label htmlFor="fl-lev" className="text-mute">{t('flLevNew')}</label><span className="font-semibold">{lev}×</span></div>175          <input id="fl-lev" type="range" min={1} max={max} step={1} value={lev} disabled={busy || max <= 1} onChange={(e) => { setLev(Number(e.target.value)); setPhase('form'); setErr(null); }} className="h-11 w-full accent-[var(--blue)]" aria-valuetext={`${lev}×`} />176          <p className="text-[11.5px] leading-snug text-mute">{t('flLevBounds', { max })}{current ? ` ${t('flLevCurrent', { lev: fmt(current.leverage, { locale, trim: true }), mode: t(current.marginMode === 'isolated' ? 'fxIsolated' : 'fxCross') })}` : ''}</p></div>177        <div role="radiogroup" aria-label={t('flLevMode')} className="grid grid-cols-2 gap-2">{(['cross', 'isolated'] as const).filter((m) => instrument.marginModes.includes(m)).map((m) =>178          <button key={m} type="button" role="radio" aria-checked={mode === m} disabled={busy} onClick={() => { setMode(m); setPhase('form'); setErr(null); }} className={`min-h-11 rounded-2xl border text-[14px] font-semibold ${mode === m ? 'border-ink text-ink' : 'border-line text-mute'}`}>{t(m === 'cross' ? 'flLevCross' : 'flLevIsolated')}</button>)}</div>179      </>}180      {p && (phase === 'ready' || phase === 'passkey' || phase === 'sending') && <>181        <h3 className="text-[13px] font-semibold">{t('flSignedExactly')}</h3>182        <div className="rounded-2xl bg-elev/70 p-3" data-testid="canonical">183          <Row label={t('flCInstrument')} strong>{p.canonical.instrument}</Row><Row label={t('flLevNew')} strong>{p.canonical.leverage}×</Row>184          <Row label={t('flLevMode')} strong>{t(p.canonical.marginMode === 'cross' ? 'flLevCross' : 'flLevIsolated')}</Row>185          <Row label={t('flLevNow')}>{p.current ? `${fmt(p.current.leverage, { locale, trim: true })}× · ${t(p.current.marginMode === 'isolated' ? 'fxIsolated' : 'fxCross')}` : '—'} <SourceTag /></Row>186          <Row label={t('flCNetwork')}>{p.canonical.network}</Row></div>187        <p className="break-all font-mono text-[10.5px] leading-snug text-mute">{t('fingerprint')} SHA-256 {p.hash}</p>188        {phase === 'ready' && <Countdown expiresAt={p.expiresAt} skewMs={skew} onExpire={() => { if (phaseRef.current === 'ready') { setErr(local('proposal_expired')); setPhase('void'); } }} />}189      </>}190      {phase === 'passkey' && <Notice tone="info">{t('flPasskeyWaiting')}</Notice>}191      {err && <LiveErrNotice err={err} tone={err.key === 'passkey_cancelled' ? 'warn' : 'danger'} />}192      {phase === 'done' && res && <Notice tone={state === 'confirmed' ? 'ok' : state === 'rejected' ? 'danger' : 'warn'}><div data-testid="leverage-result" data-state={state}>193        {t(`flLev_${state === 'confirmed' || state === 'rejected' ? state : 'unknown'}` as Key, { lev: res.venue?.leverage ? fmt(res.venue.leverage.leverage, { locale, trim: true }) : '—' })}194        {res.action.error && <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{t('flVenueSays')} {res.action.error}</span>}</div></Notice>}195      {(phase === 'form' || phase === 'void' || phase === 'proposing') && <Btn full busy={phase === 'proposing'} onClick={() => void propose()}>{t(phase === 'void' ? 'flPrepareAgain' : 'flLevPrepare')}</Btn>}196      {(phase === 'ready' || phase === 'passkey' || phase === 'sending') && <Btn full busy={phase !== 'ready'} kind={tone === 'mainnet' ? 'danger' : 'primary'} onClick={() => void approve()}><Icon n="key" className="h-4 w-4" />{t(tone === 'mainnet' ? 'flApproveReal' : 'flApproveTestnet')}</Btn>}197      <Btn full kind="ghost" disabled={busy} onClick={onClose}>{t(phase === 'done' ? 'fxClose' : 'cancel')}</Btn>198    </div>199  </Sheet>;200}201
Files main
Menu

Theme