Repository Public
1/**2 * « Déposer » / « Retirer » of a venue account (Hyperliquid): the collateral path between the wallet and the venue.3 * - Deposit: review (amount, route, who is credited, time, minimum and what happens below it, network fee, venue balance) →4 * passkey → progress. The screen says « en attente du crédit par la plateforme » until the VENUE's ledger shows the credit;5 * it never says the money arrived before that, and after the stated time it says exactly what to check.6 * - Withdrawal: review (amount, the venue's fee, what arrives, the only possible destination: this wallet) → passkey →7 * progress until the wallet's own chain scan sees the USDC.8 * The real-money band is at the top of both sheets and has no close control. Every sentence is in the `hlc` block of i18n.ts.9 */10import { startAuthentication } from '@simplewebauthn/browser';11import { useQuery, useQueryClient } from '@tanstack/react-query';12import { useContext, useEffect, useRef, useState } from 'react';13import { I18n, useT, type Key } from '../i18n';14import { Addr, Btn, Field, Icon, inputCls, Notice, Skeleton } from '../ui';15import { ageText, Row, Sheet, timeOf } from './bits';16import { bridgeLink, capi, COLLATERAL_CODES, depositIntegrity, depositProblem, opDone, opWords, withdrawIntegrity, withdrawProblem, type CollateralOp, type CollateralStatus, type DepositPrepared, type WithdrawPrepared } from './collateral';17import { fmt, usd } from './dec';18import { Bullet, LiveErrNotice, ModeBand, SourceTag } from './LiveBits';19import { liveError, toneOf, type LiveErr, type LiveSummary } from './live';20import { refreshLive } from './liveStore';2122const statusKey = (id: string) => ['deriv', 'live', 'collateral', 'status', id] as const;23const TONE_CLS = { ok: 'text-emerald bg-emerald/10', info: 'text-blue bg-blue/10', warn: 'text-warn bg-warn/12', danger: 'text-danger bg-danger/10' } as const;24const usdc = (x: string | null | undefined, locale: string) => (x === null || x === undefined ? '—' : `${fmt(x, { locale, trim: true })} USDC`);25const dur = (s: number) => (s < 90 ? `${s} s` : `${Math.round(s / 60)} min`);2627function ErrNotice({ err }: { err: LiveErr }) {28 const t = useT();29 if (!COLLATERAL_CODES.has(err.code)) return <LiveErrNotice err={err} tone={err.key === 'passkey_cancelled' ? 'warn' : 'danger'} />;30 return <Notice tone="danger"><div data-testid="collateral-error" data-code={err.code}>{t(`hlcE_${err.code}` as Key)}31 {err.detail && <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{err.code} ({err.status}) — {err.detail}</span>}</div></Notice>;32}3334/** where an operation is: four steps, one sentence, the venue balance before and after, and what to check when it is late */35export function OpProgress({ op }: { op: CollateralOp }) {36 const t = useT(); const { locale } = useContext(I18n);37 const w = opWords(op), steps = (op.kind === 'deposit' ? ['hlcStepSigned', 'hlcStepOnChain', 'hlcStepAwaitCredit', 'hlcStepCredited'] : ['hlcStepSigned', 'hlcStepVenue', 'hlcStepPayout', 'hlcStepArrived']) as Key[];38 const dead = op.final && !opDone(op);39 return <div className="min-w-0 rounded-2xl border border-line p-3.5" data-testid="collateral-op" data-kind={op.kind} data-state={op.state} data-done={opDone(op) ? '1' : '0'}>40 <div className="flex flex-wrap items-center gap-2"><span className="text-[14px] font-semibold">{t(op.kind === 'deposit' ? 'hlcDeposit' : 'hlcWithdraw')} · {usdc(op.amount, locale)}</span>41 <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-semibold ${TONE_CLS[w.tone]}`}><Icon n={w.tone === 'ok' ? 'check' : w.tone === 'danger' ? 'x' : 'clock'} className="h-3 w-3" />{t(`hlcState_${w.tone === 'ok' ? 'done' : dead ? 'stopped' : op.overdue ? 'late' : 'progress'}` as Key)}</span></div>42 {!dead && <ol className="mt-2.5 grid grid-cols-4 gap-1.5" aria-label={t('hlcSteps')}>{steps.map((k, n) => { const on = n < w.step || opDone(op), cur = n === w.step && !opDone(op);43 return <li key={k} className="min-w-0" aria-current={cur ? 'step' : undefined}><div className={`h-1.5 rounded-full ${on ? 'bg-emerald' : cur ? 'bg-blue' : 'bg-elev'}`} /><div className={`mt-1 text-[11px] leading-tight ${on || cur ? 'font-semibold text-ink' : 'text-mute'}`}>{t(k)}</div></li>; })}</ol>}44 <p className="mt-2.5 text-[13px] leading-snug" data-testid="collateral-op-words" role="status">{t(w.key as Key, { seconds: dur(op.expectedSeconds), network: op.chainNetworkName ?? '' })}</p>45 {op.overdue && <div className="mt-2"><Notice tone="warn"><div data-testid="collateral-overdue"><b className="font-semibold">{t('hlcCheckTitle', { age: ageText(op.sentAt === null ? null : Date.now() - op.sentAt) })}</b>46 <ol className="mt-1.5 list-decimal space-y-1 pl-5 [overflow-wrap:anywhere]">{op.check.map((c) => <li key={c}>{t(`hlcCheck_${c}` as Key)}</li>)}</ol></div></Notice></div>}47 {op.error && <p className="mt-2 break-words rounded-lg bg-elev/70 px-2 py-1.5 font-mono text-[11.5px] text-mute" data-testid="collateral-op-error">{op.error}</p>}48 <div className="mt-2">49 {op.balanceBefore && <Row label={t('hlcBalBefore')}>{usdc(op.balanceBefore.total, locale)}</Row>}50 <Row label={t('hlcBalAfter')} strong>{op.balanceAfter ? usdc(op.balanceAfter.total, locale) : t(opDone(op) ? 'hlcBalUnread' : 'hlcBalNotYet')}</Row>51 {op.kind === 'withdraw' && op.fee && <Row label={t('hlcFeeVenue')}>{usdc(op.ledger?.fee ?? op.fee, locale)}{op.ledger?.fee ? ` · ${t('hlcFeeCharged')}` : ''}</Row>}52 {op.kind === 'withdraw' && <Row label={t(op.arrival ? 'hlcReceived' : 'hlcReceiveExpected')}>{op.arrival ? usdc(String(Number(op.arrival.amount) / 1e6), locale) : usdc(op.expectedReceive, locale)}</Row>}53 {op.venueAt && <Row label={t('hlcVenueAt')}>{timeOf(op.venueAt, locale, true)}</Row>}54 </div>55 {op.explorer && <a href={op.explorer} target="_blank" rel="noreferrer noopener" className="mt-1.5 inline-flex min-h-9 items-center gap-1 text-[12.5px] font-medium text-blue underline-offset-2 hover:underline">{t('hlcSeeTx', { network: op.chainNetworkName ?? '' })}<Icon n="chev" className="h-3.5 w-3.5" /></a>}56 <p className="mt-1 flex items-center gap-1.5 text-[11px] text-mute"><SourceTag />{t('hlcOpSource')}</p>57 </div>;58}5960type Step = 'form' | 'preparing' | 'review' | 'passkey' | 'sending' | 'progress';61function useOp(accountId: string, opId: string | null) {62 // while the money is on its way the operation is read every few seconds; the server re-reads the venue and the chain each time63 return useQuery({ queryKey: ['deriv', 'live', 'collateral', 'op', opId], enabled: !!opId, queryFn: () => capi.operation(accountId, opId!), refetchInterval: (q) => (q.state.data?.final ? false : 4000), retry: 0 });64}65const useCountdown = (expiresAt: number | null | undefined) => {66 const [left, setLeft] = useState(0);67 useEffect(() => { if (!expiresAt) return; const tick = () => setLeft(Math.max(0, Math.round((expiresAt - Date.now()) / 1000))); tick(); const h = setInterval(tick, 500); return () => clearInterval(h); }, [expiresAt]);68 return left;69};7071function DepositSheet({ account, status, onClose }: { account: LiveSummary; status: CollateralStatus; onClose: () => void }) {72 const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();73 const r = status.route!, wl = status.wallet!, tone = toneOf(account);74 const [raw, setRaw] = useState(''), [step, setStep] = useState<Step>('form'), [prep, setPrep] = useState<DepositPrepared | null>(null), [err, setErr] = useState<LiveErr | null>(null), [bad, setBad] = useState<string | null>(null), [opId, setOpId] = useState<string | null>(null);75 const lock = useRef(false);76 const once = async (fn: () => Promise<void>) => { if (lock.current) return; lock.current = true; try { await fn(); } finally { lock.current = false; } };77 const v = depositProblem(raw, status), busy = step === 'preparing' || step === 'passkey' || step === 'sending';78 const op = useOp(account.id, opId), left = useCountdown(step === 'review' || step === 'passkey' ? prep?.operation.expiresAt : null);79 const refresh = () => { void qc.invalidateQueries({ queryKey: statusKey(account.id) }); void refreshLive(qc, account.id); };80 useEffect(() => { if (op.data?.final) refresh(); }, [op.data?.final]); // eslint-disable-line react-hooks/exhaustive-deps8182 const prepare = () => once(async () => {83 if (!v.amount) return;84 setStep('preparing'); setErr(null); setBad(null);85 try { const p = await capi.prepareDeposit(account.id, v.amount); setBad(depositIntegrity(p, status, v.amount)); setPrep(p); setStep('review'); }86 catch (e) { setErr(liveError(e)); setStep('form'); }87 });88 const approve = () => once(async () => {89 if (!prep || bad) return;90 setStep('passkey'); setErr(null);91 let assertion: unknown;92 try { assertion = await startAuthentication({ optionsJSON: prep.options }); }93 catch (e) { setErr(liveError(e)); setStep('review'); return; } // nothing was sent94 setStep('sending');95 try { await capi.approveDeposit(account.id, prep.operation.id, assertion); setOpId(prep.operation.id); setStep('progress'); refresh(); }96 catch (e) { setErr(liveError(e, 'approve')); setOpId(prep.operation.id); setStep('progress'); refresh(); } // the operation itself says what happened (refused: nothing left the wallet)97 });98 const back = () => { if (prep) void capi.cancel(account.id, prep.operation.id).catch(() => {}); setPrep(null); setStep('form'); setErr(null); };99100 return <Sheet title={t('hlcDepositTitle', { network: account.network })} onClose={() => { if (!busy) { if (prep && step === 'review') void capi.cancel(account.id, prep.operation.id).catch(() => {}); onClose(); } }}>101 <div className="min-w-0 space-y-3" data-testid="deposit-sheet" data-step={step}>102 <ModeBand tone={tone} label={account.label} />103 {(step === 'form' || step === 'preparing') && <>104 <Field label={t('hlcAmount')} hint={t('hlcDepositHint', { min: r.minimum, venueMin: r.venueMinimum, max: fmt(wl.limits.maxNow, { locale, trim: true }) })}>105 <div className="flex items-center gap-2"><input className={inputCls} inputMode="decimal" autoComplete="off" aria-label={t('hlcAmount')} value={raw} onChange={(e) => { setRaw(e.target.value.replace(/[^0-9.,]/g, '')); setErr(null); }} data-testid="deposit-amount" /><span className="w-12 shrink-0 text-[12.5px] text-mute">USDC</span></div></Field>106 <div className="rounded-2xl bg-elev/70 p-3"><Row label={t('hlcWalletHas', { network: r.networkName })} strong>{usdc(wl.usdc, locale)}</Row><Row label={t('hlcWalletGas')}>{fmt(wl.gas, { locale, dp: 6, trim: true })} {wl.gasSymbol}</Row>107 <Row label={t('hlcLimitTx')}>{usdc(wl.limits.perTransfer, locale)}</Row><Row label={t('hlcLimitDay')}>{usdc(wl.limits.perDay, locale)} · {t('hlcLimitSpent', { spent: fmt(wl.limits.spentToday, { locale, trim: true }) })}</Row></div>108 <Notice tone="warn"><div data-testid="deposit-minimum"><b className="font-semibold">{t('hlcMinTitle', { min: r.minimum })}</b> {t('hlcMinBody', { venueMin: r.venueMinimum, min: r.minimum })}</div></Notice>109 {v.problem && <Notice tone="danger"><div data-testid="deposit-problem">{t(v.problem, { min: r.minimum, venueMin: r.venueMinimum, have: fmt(wl.usdc, { locale, trim: true }), limit: fmt(wl.limits.perTransfer, { locale, trim: true }), network: r.networkName })}</div></Notice>}110 {err && <ErrNotice err={err} />}111 <Btn full busy={step === 'preparing'} disabled={!v.amount} onClick={() => void prepare()} data-testid="deposit-review">{t('hlcReview')}</Btn>112 {step === 'preparing' && <p className="text-center text-[12px] text-mute">{t('hlcPreparing')}</p>}113 </>}114115 {prep && (step === 'review' || step === 'passkey' || step === 'sending') && <>116 <div className="rounded-2xl border border-line p-3.5" data-testid="deposit-facts">117 <Row label={t('hlcAmount')} strong>{usdc(prep.operation.amount, locale)}</Row>118 <Row label={t('hlcRoute')}>{t('hlcRouteValue', { network: prep.route.networkName })}</Row>119 <Row label={t('hlcTime')}>{t('hlcTimeDeposit', { seconds: dur(prep.route.expectedSeconds) })}</Row>120 <Row label={t('hlcMinimum')}>{usdc(prep.route.minimum, locale)} · {t('hlcMinimumVenue', { venueMin: prep.route.venueMinimum })}</Row>121 <Row label={t('hlcFeeNetwork', { network: prep.route.networkName })}>≈ {fmt(prep.fee.estimate, { locale, dp: 7, trim: true })} {prep.fee.symbol} · max {fmt(prep.fee.max, { locale, dp: 7, trim: true })}</Row>122 <Row label={t('hlcFeeVenue')}>0 USDC</Row>123 <Row label={t('hlcBalBefore')}>{usdc(prep.venueBalance.total, locale)}</Row>124 </div>125 <ul className="space-y-2 text-[13px] leading-snug" data-testid="deposit-says">126 <Bullet icon="key">{t('hlcCreditsSender')}<span className="mt-1 block"><Addr value={prep.intent.from} /></span></Bullet>127 <Bullet icon="shield">{t('hlcBridgePinned')}<span className="mt-1 block"><Addr value={prep.intent.to} /></span></Bullet>128 <Bullet icon="warn" tone="text-warn">{t('hlcLostBelow', { venueMin: prep.route.venueMinimum, min: prep.route.minimum })}</Bullet>129 <Bullet icon="clock">{t('hlcNotBefore')}</Bullet>130 <Bullet icon="check" tone="text-emerald">{t('hlcLiveness')}<span className="mt-0.5 block break-words font-mono text-[11px] text-mute">{prep.liveness.detail} · {timeOf(prep.liveness.checkedAt, locale)}</span></Bullet>131 </ul>132 <p className="break-all font-mono text-[10.5px] text-mute">{t('fingerprint')} SHA-256 {prep.operation.hash}</p>133 {bad && <Notice tone="danger"><div data-testid="collateral-integrity">{t(`hlcIntegrity_${bad}` as Key)}</div></Notice>}134 {step === 'passkey' && <Notice tone="info">{t('flPasskeyWaiting')}</Notice>}135 {err && <ErrNotice err={err} />}136 <Btn full kind={tone === 'mainnet' ? 'danger' : 'primary'} busy={step !== 'review'} disabled={!!bad || left === 0} onClick={() => void approve()} data-testid="deposit-approve"><Icon n="key" className="h-4 w-4" />{t('hlcDepositApprove', { amount: fmt(prep.operation.amount, { locale, trim: true }) })}</Btn>137 <p className="text-center text-[12px] text-mute">{left > 0 ? `${t('expiresIn')} ${left} s` : t('hlcExpired')}</p>138 <Btn full kind="ghost" disabled={busy} onClick={back}>{t('hlcBack')}</Btn>139 </>}140141 {step === 'progress' && <div className="space-y-3" data-testid="deposit-progress">142 {op.data ? <OpProgress op={op.data} /> : <Skeleton h="h-32" />}143 {err && !op.data?.final && <ErrNotice err={err} />}144 <p className="text-[12px] leading-snug text-mute">{t('hlcCloseSafe')}</p>145 <Btn full kind="soft" onClick={onClose}>{t('fxClose')}</Btn></div>}146 </div>147 </Sheet>;148}149150function WithdrawSheet({ account, status, onClose }: { account: LiveSummary; status: CollateralStatus; onClose: () => void }) {151 const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();152 const wd = status.withdrawal, tone = toneOf(account);153 const [raw, setRaw] = useState(''), [step, setStep] = useState<Step>('form'), [prep, setPrep] = useState<WithdrawPrepared | null>(null), [err, setErr] = useState<LiveErr | null>(null), [bad, setBad] = useState<string | null>(null), [opId, setOpId] = useState<string | null>(null);154 const lock = useRef(false);155 const once = async (fn: () => Promise<void>) => { if (lock.current) return; lock.current = true; try { await fn(); } finally { lock.current = false; } };156 const v = withdrawProblem(raw, status), busy = step === 'preparing' || step === 'passkey' || step === 'sending';157 const op = useOp(account.id, opId), left = useCountdown(step === 'review' || step === 'passkey' ? prep?.operation.expiresAt : null);158 const refresh = () => { void qc.invalidateQueries({ queryKey: statusKey(account.id) }); void refreshLive(qc, account.id); };159 useEffect(() => { if (op.data?.final) { refresh(); void qc.invalidateQueries({ queryKey: ['portfolio'] }); } }, [op.data?.final]); // eslint-disable-line react-hooks/exhaustive-deps160161 const prepare = () => once(async () => {162 if (!v.amount) return;163 setStep('preparing'); setErr(null); setBad(null);164 try { const p = await capi.prepareWithdraw(account.id, v.amount); setBad(withdrawIntegrity(p, status, v.amount)); setPrep(p); setStep('review'); }165 catch (e) { setErr(liveError(e)); setStep('form'); }166 });167 const approve = () => once(async () => {168 if (!prep || bad) return;169 setStep('passkey'); setErr(null);170 let assertion: unknown;171 try { assertion = await startAuthentication({ optionsJSON: prep.options }); }172 catch (e) { setErr(liveError(e)); setStep('review'); return; }173 setStep('sending');174 try { await capi.approveWithdraw(account.id, prep.operation.id, assertion); } catch (e) { setErr(liveError(e, 'approve')); }175 setOpId(prep.operation.id); setStep('progress'); refresh();176 });177 const back = () => { if (prep) void capi.cancel(account.id, prep.operation.id).catch(() => {}); setPrep(null); setStep('form'); setErr(null); };178179 return <Sheet title={t('hlcWithdrawTitle', { network: account.network })} onClose={() => { if (!busy) { if (prep && step === 'review') void capi.cancel(account.id, prep.operation.id).catch(() => {}); onClose(); } }}>180 <div className="min-w-0 space-y-3" data-testid="withdraw-sheet" data-step={step}>181 <ModeBand tone={tone} label={account.label} />182 {(step === 'form' || step === 'preparing') && <>183 <Field label={t('hlcAmount')} hint={t('hlcWithdrawHint', { fee: wd.fee })}>184 <div className="flex items-center gap-2"><input className={inputCls} inputMode="decimal" autoComplete="off" aria-label={t('hlcAmount')} value={raw} onChange={(e) => { setRaw(e.target.value.replace(/[^0-9.,]/g, '')); setErr(null); }} data-testid="withdraw-amount" /><span className="w-12 shrink-0 text-[12.5px] text-mute">USDC</span></div></Field>185 <div className="rounded-2xl bg-elev/70 p-3"><Row label={t('hlcVenueAvailable')} strong>{usdc(wd.available, locale)}</Row><Row label={t('hlcFeeVenue')}>{usdc(wd.fee, locale)}</Row>186 <Row label={t('hlcReceiveExpected')} strong>{v.receive ? usdc(v.receive, locale) : '—'}</Row></div>187 {v.problem && <Notice tone="danger"><div data-testid="withdraw-problem">{t(v.problem, { fee: wd.fee, have: fmt(wd.available ?? '0', { locale, trim: true }) })}</div></Notice>}188 {err && <ErrNotice err={err} />}189 <Btn full busy={step === 'preparing'} disabled={!v.amount} onClick={() => void prepare()} data-testid="withdraw-review">{t('hlcReview')}</Btn>190 </>}191192 {prep && (step === 'review' || step === 'passkey' || step === 'sending') && <>193 <div className="rounded-2xl border border-line p-3.5" data-testid="withdraw-facts">194 <Row label={t('hlcAmount')} strong>{usdc(prep.withdrawal.amount, locale)}</Row>195 <Row label={t('hlcFeeVenue')}>{usdc(prep.withdrawal.fee, locale)}</Row>196 <Row label={t('hlcReceiveExpected')} strong>{usdc(prep.receive, locale)}</Row>197 <Row label={t('hlcRoute')}>{t('hlcRouteBack', { network: wd.payoutNetworkName })}</Row>198 <Row label={t('hlcTime')}>{t('hlcTimeWithdraw', { seconds: dur(wd.expectedSeconds) })}</Row>199 <Row label={t('hlcBalBefore')}>{usdc(prep.venueBalance.total, locale)}</Row>200 </div>201 <ul className="space-y-2 text-[13px] leading-snug" data-testid="withdraw-says">202 <Bullet icon="shield">{t('hlcOwnOnly')}<span className="mt-1 block"><Addr value={prep.withdrawal.destination} /></span></Bullet>203 <Bullet icon="key">{t('hlcSignedMain')}<span className="mt-0.5 block break-words font-mono text-[11px] text-mute">{prep.whatIsSigned.byMainKey}</span></Bullet>204 <Bullet icon="clock">{t(wd.arrivalTracked ? 'hlcNotBeforeArrival' : 'hlcUntracked', { network: wd.payoutNetworkName })}</Bullet>205 </ul>206 <p className="break-all font-mono text-[10.5px] text-mute">{t('fingerprint')} SHA-256 {prep.hash}</p>207 {bad && <Notice tone="danger"><div data-testid="collateral-integrity">{t(`hlcIntegrity_${bad}` as Key)}</div></Notice>}208 {step === 'passkey' && <Notice tone="info">{t('flPasskeyWaiting')}</Notice>}209 {err && <ErrNotice err={err} />}210 <Btn full kind={tone === 'mainnet' ? 'danger' : 'primary'} busy={step !== 'review'} disabled={!!bad || left === 0} onClick={() => void approve()} data-testid="withdraw-approve"><Icon n="key" className="h-4 w-4" />{t('hlcWithdrawApprove', { amount: fmt(prep.withdrawal.amount, { locale, trim: true }) })}</Btn>211 <p className="text-center text-[12px] text-mute">{left > 0 ? `${t('expiresIn')} ${left} s` : t('hlcExpired')}</p>212 <Btn full kind="ghost" disabled={busy} onClick={back}>{t('hlcBack')}</Btn>213 </>}214215 {step === 'progress' && <div className="space-y-3" data-testid="withdraw-progress">216 {op.data ? <OpProgress op={op.data} /> : <Skeleton h="h-32" />}217 {err && !op.data?.final && <ErrNotice err={err} />}218 <p className="text-[12px] leading-snug text-mute">{t('hlcCloseSafe')}</p>219 <Btn full kind="soft" onClick={onClose}>{t('fxClose')}</Btn></div>}220 </div>221 </Sheet>;222}223224/** inside a venue account card: the venue balance, the two buttons with the reason when one is closed, and the operations */225export function CollateralPanel({ account }: { account: LiveSummary }) {226 const t = useT(); const { locale } = useContext(I18n);227 const q = useQuery({ queryKey: statusKey(account.id), queryFn: () => capi.status(account.id), retry: 0,228 refetchInterval: (x) => (x.state.data?.operations.some((o) => !o.final && o.state !== 'pending_approval') ? 5000 : 30_000) });229 const [sheet, setSheet] = useState<'deposit' | 'withdraw' | null>(null);230 const s = q.data;231 if (q.isLoading) return <Skeleton h="h-20" />;232 if (!s) return <Notice tone="warn"><div data-testid="collateral-unavailable">{t('hlcUnavailable')}{q.error ? <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{liveError(q.error).detail || liveError(q.error).code}</span> : null}</div></Notice>;233 const shown = s.operations.filter((o) => o.state !== 'cancelled' && o.state !== 'pending_approval' && o.state !== 'expired').slice(0, 3);234 const dep = s.deposit, wd = s.withdraw;235 return <div className="min-w-0 rounded-2xl border border-line p-3.5" data-testid="collateral-panel">236 <div className="flex flex-wrap items-center justify-between gap-2"><span className="text-[13.5px] font-semibold">{t('hlcTitle')}</span>237 <span className="text-[13px]">{t('hlcAtVenue')} <b className="font-semibold" data-testid="collateral-venue-balance">{s.venueBalance.error ? '—' : usd(s.venueBalance.total, locale)}</b></span></div>238 <div className="mt-2.5 flex flex-wrap gap-2">239 <Btn disabled={!dep.available} onClick={() => setSheet('deposit')} data-testid="collateral-deposit"><Icon n="down" className="h-4 w-4" />{t('hlcDeposit')}</Btn>240 <Btn kind="soft" disabled={!wd.available} onClick={() => setSheet('withdraw')} data-testid="collateral-withdraw"><Icon n="up" className="h-4 w-4" />{t('hlcWithdraw')}</Btn>241 </div>242 {!dep.available && dep.reason && <p className="mt-2 text-[12.5px] leading-snug text-mute" data-testid="deposit-why" data-reason={dep.reason}>{t(`hlcWhy_${dep.reason}` as Key, { network: s.route?.networkName ?? '', min: s.route?.minimum ?? '' })}243 {s.walletError && dep.reason === 'wallet_unreadable' ? <span className="mt-0.5 block break-words font-mono text-[11px]">{s.walletError}</span> : null}</p>}244 {dep.reason === 'usdc_elsewhere' && s.route && <ul className="mt-1.5 space-y-1" data-testid="collateral-bridge-links">{dep.bridgeFrom.map((b) => <li key={b.network}>245 <a href={bridgeLink(b.network, s.route!.network)} className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-line px-3 text-[13px] font-medium text-blue">246 <Icon n="swap" className="h-4 w-4" />{t('hlcBridgeLink', { from: b.name, to: s.route!.networkName, amount: fmt(b.usdc, { locale, trim: true }) })}</a></li>)}</ul>}247 {!wd.available && wd.reason && wd.reason !== dep.reason && <p className="mt-2 text-[12.5px] leading-snug text-mute" data-testid="withdraw-why">{t(`hlcWhy_${wd.reason}` as Key, { network: '', min: '' })}</p>}248 {s.route && <p className="mt-2 text-[12px] leading-snug text-mute">{t('hlcPanelNote', { min: s.route.minimum, network: s.route.networkName, fee: s.withdrawal.fee })}</p>}249 {s.venueBalance.abstraction && s.venueBalance.abstraction !== 'default' && <p className="mt-1 text-[12px] leading-snug text-mute">{t('hlcUnifiedNote')} <span className="font-mono text-[11px]">{s.venueBalance.abstraction}</span></p>}250 {shown.length > 0 && <ul className="mt-3 space-y-2" data-testid="collateral-ops">{shown.map((o) => <li key={o.id}><OpProgress op={o} /></li>)}</ul>}251 {sheet === 'deposit' && s.route && s.wallet && <DepositSheet account={account} status={s} onClose={() => { setSheet(null); void q.refetch(); }} />}252 {sheet === 'withdraw' && <WithdrawSheet account={account} status={s} onClose={() => { setSheet(null); void q.refetch(); }} />}253 </div>;254}255