Repository Public
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