SPB Git

Repository Public

AgentFund.tsx 39.5 KB 340 lines tsx

1// Funding an agent's wallet from the main wallet, bringing funds back, and the readiness checklist of the agent page.2// Nothing here is a new money path: funding is the ordinary transfer flow (draft → frozen intent → passkey → signer),3// with the network and the recipient fixed to the agent's own; withdrawing is the existing vault-return route.4import { startAuthentication } from '@simplewebauthn/browser';5import { useQuery, useQueryClient } from '@tanstack/react-query';6import { useContext, useEffect, useMemo, useState, type ReactNode } from 'react';7import { get, post } from '../api';8import type { Nav } from '../App';9import { useAccounts, useNetworks, usePortfolio, type Net, type Proposal } from '../hooks';10import { errText, I18n, useT, type Key } from '../i18n';11import { Addr, Btn, Coin, CopyBtn, Icon, inputCls, NetDot, Notice, trimAmt } from '../ui';1213type T = (k: Key, v?: Record<string, string | number>) => string;14export interface VaultRow { symbol: string; assetSymbol: string; name: string; kind: string; issuer: string | null; role: 'cash' | 'asset' | 'gas'; asset: string; amount: string; display: string; usd: number | null }15export interface FundAgent { id: string; name: string; state: string; network: string; networkName: string; cash: string; capitalUsdc: string; vaultAddress: string; strategyVersionId: string | null;16  holdings: { symbol: string; asset: string; assetSymbol: string; name: string; kind: string }[] | null; vault: VaultRow[] | null }17interface GasInfo { reserve: string; perOrder: string; perOrderUsd: number | null }18export interface AgentNet { id: string; name: string; native: string; gas: GasInfo | null; feeWarning: boolean; symbols: { symbol: string; live: boolean }[] }19interface Overview { broadcast?: boolean; signer: { reachable: boolean; halted?: boolean; policy?: { perTx: Record<string, string>; daily: Record<string, string> }; spentToday?: Record<string, string> } }20interface Elsewhere { network: string; networkName: string; asset: string; symbol: string; display: string; gas: string; hasGas: boolean; gasHeld?: string; gasNeed?: string | null; gasEnough?: boolean | null }2122// ── exact decimal arithmetic on display strings (amounts never go through floating point) ─────────────────────────────────23export const toUnits = (s: string, dec: number): bigint => { const m = /^(\d*)(?:\.(\d*))?$/.exec(s.trim()); if (!m || (!m[1] && !m[2])) return 0n; return BigInt((m[1] || '0') + (m[2] ?? '').slice(0, dec).padEnd(dec, '0')); };24export const fromUnits = (v: bigint, dec: number): string => { const neg = v < 0n, s = (neg ? -v : v).toString().padStart(dec + 1, '0'); const i = s.slice(0, s.length - dec), f = dec ? s.slice(s.length - dec).replace(/0+$/, '') : ''; return (neg ? '-' : '') + (f ? `${i}.${f}` : i); };25const minBig = (...xs: bigint[]) => xs.reduce((a, b) => (b < a ? b : a));26/** Round a base-unit amount UP to `keep` significant decimals (a gas top-up must not fall one wei short). */27const ceilTo = (v: bigint, dec: number, keep: number): bigint => { if (dec <= keep) return v; const q = 10n ** BigInt(dec - keep); return ((v + q - 1n) / q) * q; };28const sameAsset = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();29const TEST_NOT_SENT = /broadcast disabled/i;30const OK = ['confirming', 'finalized'], BAD = ['failed', 'rejected', 'expired', 'replaced'];3132/** Plain words for a refusal of the transfer flow; the server's English detail is only used when no code is known. */33export function fundErr(t: T, e: unknown, ctx: { sym: string; net: string; native: string; perTx?: string; left?: string }): string {34  const x = e as { code?: string; message?: string; name?: string };35  if (x?.name === 'NotAllowedError' || x?.name === 'AbortError') return t('qaFundPasskeyCancelled');36  const m = x?.message ?? '';37  switch (x?.code) {38    case 'policy': return /per-transaction/.test(m) ? t('qaFundErrPerTx', { max: ctx.perTx ?? '?', sym: ctx.sym }) : /daily limit/.test(m) ? t('qaFundErrDaily', { left: ctx.left ?? '0', sym: ctx.sym }) : /fee ceiling/.test(m) ? t('qaFundErrFee', { net: ctx.net }) : /halt/.test(m) ? t('e_halted') : `${t('e_policy')} (${m})`;39    case 'insufficient_funds': return t('qaFundErrBalance', { sym: ctx.sym, net: ctx.net });40    case 'insufficient_gas': return t('qaFundErrGas', { native: ctx.native, net: ctx.net });41    case 'pending_tx': return t('qaFundErrPending', { net: ctx.net });42    case 'halted': return t('e_halted');43    case 'expired': return t('qaFundErrExpired');44    case 'offline': return t('offline');45    default: return errText(t, e);46  }47}4849/** One ordinary transfer of the main wallet, up to the signer: draft, frozen intent, passkey over its hash, approval. */50async function sendTransfer(o: { network: string; asset: string; to: string; amount: string; note: string; idem: string; onDraft?: (id: string) => void }): Promise<Proposal> {51  const p = await post<Proposal>('/transfers/proposals', { network: o.network, asset: o.asset, to: o.to, amount: o.amount, note: o.note.slice(0, 80) }, { 'idempotency-key': o.idem });52  o.onDraft?.(p.id);53  if (!['draft', 'pending_approval'].includes(p.state)) return p;                     // resumed: already past the passkey54  const ra = await post<{ proposal: Proposal; options: never }>(`/transfers/proposals/${p.id}/request-approval`);55  const assertion = await startAuthentication({ optionsJSON: ra.options });           // challenge = fingerprint of this exact transfer56  return post<Proposal>(`/transfers/proposals/${p.id}/approve`, { assertion });57}5859function Sheet({ title, onClose, children, label }: { title: string; label?: string; onClose: () => void; children: ReactNode }) {60  const t = useT();61  useEffect(() => { const f = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; addEventListener('keydown', f); return () => removeEventListener('keydown', f); }, [onClose]);62  return <div className="fixed inset-0 z-30 flex items-end justify-center bg-black/45 lg:items-center" role="dialog" aria-modal="true" aria-label={label ?? title}>63    <div className="rise safe-b max-h-[92vh] w-full max-w-xl overflow-y-auto rounded-t-3xl bg-surface p-5 lg:rounded-3xl">64      <div className="mb-3 flex items-start justify-between gap-3"><h2 className="text-[20px] font-semibold leading-tight tracking-tight">{title}</h2>65        <button onClick={onClose} aria-label={t('qaClose')} className="-mr-2 -mt-1 grid h-11 w-11 shrink-0 place-items-center rounded-full text-mute"><Icon n="x" /></button></div>66      {children}67    </div></div>;68}69const NetBanner = ({ net, children }: { net: string; children: ReactNode }) => <div className="flex items-center gap-2.5 rounded-2xl border border-primary/25 bg-elev/70 px-4 py-3 text-[15px] font-semibold"><NetDot net={net} /><span>{children}</span></div>;7071// ── funding ───────────────────────────────────────────────────────────────────────────────────────────────────────────────72interface Leg { key: 'cash' | 'gas'; asset: string; symbol: string; amount: string; idem: string; proposalId?: string; state?: string; error?: string; txError?: string | null; explorer?: string | null }73const store = (id: string) => `spw-fund-${id}`;74const loadLegs = (id: string): Leg[] | null => { try { const j = JSON.parse(localStorage.getItem(store(id)) ?? 'null') as { at: number; legs: Leg[] } | null; return j && Date.now() - j.at < 86_400_000 && Array.isArray(j.legs) ? j.legs : null; } catch { return null; } };75const legDone = (l: Leg) => OK.includes(l.state ?? '');76const legTest = (l: Leg) => l.state === 'broadcast' && TEST_NOT_SENT.test(l.txError ?? '');77/** True when a funding run of this agent was started and is not finished (the agent page offers to resume it). */78export const fundingInProgress = (id: string): boolean => { const l = loadLegs(id); return !!l && l.some((x) => !!x.proposalId || !!x.error) && !l.every(legDone); };7980export function FundSheet({ a, gas, nav, onClose }: { a: FundAgent; gas: GasInfo | null; nav: Nav; onClose: () => void }) {81  const t = useT(); const qc = useQueryClient();82  const pf = usePortfolio(), nets = useNetworks(), acc = useAccounts();83  const ov = useQuery({ queryKey: ['security'], queryFn: () => get<Overview>('/security/overview'), staleTime: 15_000 });84  const br = useQuery({ queryKey: ['bridge-exec'], queryFn: () => get<{ networks: string[] }>('/bridge/execution'), staleTime: 600_000 });85  const n: Net | undefined = nets.data?.find((x) => x.id === a.network);86  const cashId = a.holdings?.[0]?.asset ?? a.vault?.find((x) => x.role === 'cash')?.asset ?? '';87  const cashDef = n?.tokens.find((x) => sameAsset(x.id, cashId)), native = n?.native;88  const cashDec = cashDef?.decimals ?? 6, natDec = native?.decimals ?? 18, natSym = native?.symbol ?? '';89  const cashName = a.holdings?.[0]?.name ?? a.cash;90  const main = (asset: string) => pf.data?.balances.find((b) => b.network === a.network && sameAsset(b.asset, asset));91  const mainCash = BigInt(main(cashId)?.amount ?? '0'), mainNat = BigInt(main('native')?.amount ?? '0');92  const vaultOf = (asset: string) => BigInt(a.vault?.find((x) => sameAsset(x.asset, asset))?.amount ?? '0');93  const vCash = vaultOf(cashId), vNat = vaultOf('native');94  const reserve = gas ? toUnits(gas.reserve, natDec) : null;95  const missing = reserve !== null && reserve > vNat ? ceilTo(reserve - vNat, natDec, 6) : 0n;96  const pol = ov.data?.signer.policy, spent = ov.data?.signer.spentToday ?? {};97  const cap = (sym: string, dec: number) => { const k = `${a.network}:${sym}`; if (!pol || pol.perTx[k] === undefined) return null; const per = BigInt(pol.perTx[k]), day = BigInt(pol.daily[k] ?? '0'), used = BigInt(spent[k] ?? '0'); const left = day > used ? day - used : 0n; return { per, left, perD: fromUnits(per, dec), leftD: fromUnits(left, dec), dayD: fromUnits(day, dec) }; };98  const capCash = cap(a.cash, cashDec), capNat = cap(natSym, natDec);99  const maxCash = minBig(mainCash, ...(capCash ? [capCash.per, capCash.left] : []));100  const planned = toUnits(a.capitalUsdc, cashDec);101102  const [legs, setLegsRaw] = useState<Leg[] | null>(() => loadLegs(a.id));103  const setLegs = (l: Leg[] | null) => { setLegsRaw(l); if (l) localStorage.setItem(store(a.id), JSON.stringify({ at: Date.now(), legs: l })); else localStorage.removeItem(store(a.id)); };104  const [cashAmt, setCashAmt] = useState(''), [gasAmt, setGasAmt] = useState(''), [busy, setBusy] = useState(false), [touched, setTouched] = useState(false);105  // suggested amounts, once the balances are known: what is missing to reach the planned capital / the gas reserve106  useEffect(() => { if (touched || legs || !pf.data || !n) return;107    const want = planned > vCash ? planned - vCash : 0n, c = minBig(want, maxCash);108    setCashAmt(c > 0n ? fromUnits(c, cashDec) : ''); setGasAmt(missing > 0n ? fromUnits(missing, natDec) : '');109  }, [pf.data, n, ov.data, a.vault, touched, legs]); // eslint-disable-line react-hooks/exhaustive-deps110  const cashU = toUnits(cashAmt, cashDec), gasU = toUnits(gasAmt, natDec);111  const cashTooMuch = cashU > mainCash, cashOverPer = !!capCash && cashU > capCash.per, cashOverDay = !!capCash && cashU > capCash.left;112  const gasTooMuch = gasU > 0n && gasU >= mainNat, gasOverPer = !!capNat && gasU > capNat.per;113  const usdcElsewhere = a.cash === 'USDC' ? (pf.data?.balances ?? []).filter((b) => b.symbol === 'USDC' && b.network !== a.network && b.amount !== null && b.amount !== '0') : [];114  const canBridge = (from: string) => !!br.data?.networks.includes(from) && br.data.networks.includes(a.network);115  const myAddr = acc.data?.accounts.find((x) => x.network === a.network)?.address;116117  // progress of the transfer in flight (and of a resumed one)118  const cur = legs?.find((l) => l.proposalId && !legDone(l) && !BAD.includes(l.state ?? ''));119  const live = useQuery({ queryKey: ['proposal', cur?.proposalId], enabled: !!cur?.proposalId, refetchInterval: 3000, queryFn: () => get<Proposal>(`/transfers/proposals/${cur!.proposalId}`) });120  useEffect(() => { const p = live.data; if (!p || !legs || !cur || p.id !== cur.proposalId) return;121    if (p.state === cur.state && (p.error ?? null) === (cur.txError ?? null)) return;122    setLegs(legs.map((l) => (l.proposalId === p.id ? { ...l, state: p.state, txError: p.error, explorer: p.explorer } : l)));123    if (OK.includes(p.state)) { void qc.invalidateQueries({ queryKey: ['agent', a.id] }); void qc.invalidateQueries({ queryKey: ['portfolio'] }); }124  }, [live.data]); // eslint-disable-line react-hooks/exhaustive-deps125  const ctxOf = (l: Leg) => ({ sym: l.symbol, net: a.networkName, native: natSym, perTx: (l.key === 'cash' ? capCash : capNat)?.perD, left: (l.key === 'cash' ? capCash : capNat)?.leftD });126  const run = async (all: Leg[], k: number) => {127    const l = all[k]!; let next = all.map((x, i) => (i === k ? { ...x, error: undefined } : x)); setLegs(next); setBusy(true);128    const patch = (p: Partial<Leg>) => { next = next.map((x, i) => (i === k ? { ...x, ...p } : x)); setLegs(next); };129    try {130      const p = await sendTransfer({ network: a.network, asset: l.asset, to: a.vaultAddress, amount: l.amount, note: `Agent ${a.name}`, idem: l.idem, onDraft: (id) => patch({ proposalId: id, state: 'draft' }) });131      patch({ proposalId: p.id, state: p.state, txError: p.error, explorer: p.explorer });132      void qc.invalidateQueries({ queryKey: ['security'] });133    } catch (e) {134      // a draft left behind is cancelled so that a retry starts clean (a new idempotency key)135      const id = next[k]!.proposalId; if (id) await post(`/transfers/proposals/${id}/reject`).catch(() => {});136      patch({ error: fundErr(t, e, ctxOf(l)), proposalId: undefined, state: undefined, idem: crypto.randomUUID() });137    } finally { setBusy(false); }138  };139  const start = () => { const l: Leg[] = [];140    if (cashU > 0n) l.push({ key: 'cash', asset: cashId, symbol: a.cash, amount: fromUnits(cashU, cashDec), idem: crypto.randomUUID() });141    if (gasU > 0n) l.push({ key: 'gas', asset: 'native', symbol: natSym, amount: fromUnits(gasU, natDec), idem: crypto.randomUUID() });142    if (l.length) void run(l, 0); };143  const finish = () => { setLegs(null); void qc.invalidateQueries({ queryKey: ['agent', a.id] }); void qc.invalidateQueries({ queryKey: ['portfolio'] }); onClose(); };144  const row = (k: string, v: ReactNode) => <div className="flex justify-between gap-3"><dt className="text-mute">{k}</dt><dd className="text-right font-medium">{v}</dd></div>;145  const toSwap = (from: string) => { sessionStorage.setItem('spw-swap-prefill', JSON.stringify({ fromNetwork: from, toNetwork: a.network, symbol: 'USDC' })); nav({ tab: 'swap' }); };146147  const title = t('qaFundTitle', { name: a.name });148  if (legs) { // ── progress: one transfer after the other, each with its own passkey prompt149    const nextK = legs.findIndex((l) => !legDone(l)), allDone = nextK < 0, stuck = legs.some(legTest);150    return <Sheet title={title} onClose={onClose}>151      <NetBanner net={a.network}>{t('qaFundOnly', { net: a.networkName })}</NetBanner>152      <ol className="mt-4 space-y-3" aria-label={t('qaFundProgress')}>{legs.map((l, k) => { const done = legDone(l), test = legTest(l), bad = BAD.includes(l.state ?? ''), waiting = !!l.proposalId && !done && !bad && !test, isNext = k === nextK;153        return <li key={l.key} className="rounded-2xl border border-line p-3.5" data-leg={l.key} data-state={done ? 'done' : test ? 'test' : bad ? 'failed' : waiting ? 'waiting' : l.error ? 'error' : 'todo'}>154          <div className="flex items-center gap-3"><span className={`grid h-7 w-7 shrink-0 place-items-center rounded-full text-[12px] font-semibold ${done ? 'bg-emerald/15 text-emerald' : bad || l.error ? 'bg-danger/10 text-danger' : waiting ? 'bg-primary text-onprimary' : 'bg-elev text-mute'}`}>{done ? <Icon n="check" className="h-3.5 w-3.5" /> : waiting ? <Icon n="spin" className="h-3.5 w-3.5" /> : bad || l.error ? <Icon n="x" className="h-3.5 w-3.5" /> : k + 1}</span>155            <span className="min-w-0 flex-1"><span className="block text-[15px] font-semibold">{k + 1}/{legs.length} · {trimAmt(l.amount, 8)} {l.symbol}{l.key === 'gas' ? ` (${t('agGasRole')})` : ''}</span>156              <span className="block text-[13px] text-mute">{done ? (l.state === 'finalized' ? t('qaFundLegFinal') : t('qaFundLegConfirming')) : test ? t('qaFundLegTest') : bad ? t('qaFundLegFailed') : waiting ? (l.state === 'broadcast' || l.state === 'signed' ? t('qaFundLegSent') : t('qaFundLegSigning')) : isNext && busy ? t('qaFundLegPasskey') : t('qaFundLegTodo')}</span></span></div>157          {l.error && <div className="mt-2.5"><Notice tone="danger">{l.error}</Notice></div>}158          {bad && l.txError && <div className="mt-2.5"><Notice tone="danger">{l.txError}</Notice></div>}159          {l.explorer && !test && <a className="mt-2 inline-flex min-h-11 items-center gap-1.5 text-sm text-blue" href={l.explorer} target="_blank" rel="noreferrer noopener"><Icon n="ext" className="h-4 w-4" />{t('viewExplorer')}</a>}160        </li>; })}</ol>161      {stuck && <div className="mt-3"><Notice tone="warn">{t('qaFundTestMode')}</Notice></div>}162      {allDone && <div className="mt-3"><Notice tone="ok">{t('qaFundAllDone', { net: a.networkName })}</Notice></div>}163      <div className="mt-4 space-y-2.5">164        {!allDone && !stuck && (() => { const l = legs[nextK]!, waiting = !!l.proposalId && !BAD.includes(l.state ?? '');165          if (waiting) return <p className="flex items-center gap-2 text-[13px] text-mute"><Icon n="spin" className="h-4 w-4" />{t('qaFundWait', { net: a.networkName })}</p>;166          const retry = !!l.error || BAD.includes(l.state ?? '');167          return <Btn full busy={busy} onClick={() => void run(retry && BAD.includes(l.state ?? '') ? legs.map((x, i) => (i === nextK ? { ...x, proposalId: undefined, state: undefined, txError: undefined, idem: crypto.randomUUID() } : x)) : legs, nextK)}><Icon n="key" />{retry ? t('qaFundRetry', { k: nextK + 1, n: legs.length, amount: trimAmt(l.amount, 8), sym: l.symbol }) : t('qaFundStep', { k: nextK + 1, n: legs.length, amount: trimAmt(l.amount, 8), sym: l.symbol })}</Btn>; })()}168        {allDone || stuck ? <Btn full kind={allDone ? 'primary' : 'soft'} onClick={finish}>{t('qaFundFinish')}</Btn>169          : <><Btn full kind="ghost" disabled={busy} onClick={onClose}>{t('qaFundLater')}</Btn>170            {!legs.some((l) => l.proposalId && !BAD.includes(l.state ?? '')) && <Btn full kind="ghost" disabled={busy} onClick={() => setLegs(null)}>{t('qaFundAbandon')}</Btn>}</>}171        {!allDone && !stuck && <p className="text-[12.5px] text-mute">{t('qaFundResumeNote')}</p>}172      </div>173    </Sheet>;174  }175176  const nothing = cashU === 0n && gasU === 0n, blocked = cashTooMuch || cashOverPer || cashOverDay || gasTooMuch || gasOverPer || !!ov.data?.signer.halted;177  return <Sheet title={title} onClose={onClose}>178    <NetBanner net={a.network}>{t('qaFundOnly', { net: a.networkName })}</NetBanner>179    <div className="mt-3 rounded-2xl bg-elev/70 p-3.5"><div className="mb-1 text-[12px] text-mute">{t('qaFundTo', { net: a.networkName })}</div><Addr value={a.vaultAddress} /><div className="mt-1 text-[12px] text-mute">{t('qaFundToNote')}</div></div>180    {!pf.data || !n ? <p className="mt-4 flex items-center gap-2 text-[13.5px] text-mute"><Icon n="spin" className="h-4 w-4" />{t('qaLoading')}</p> : <>181      <section className="mt-4" aria-label={a.cash}>182        <div className="mb-1.5 flex items-baseline justify-between gap-3"><label htmlFor="qa-fund-cash" className="text-[14px] font-semibold">{a.cash} <span className="font-normal text-mute">· {cashName} · {t('qaFundCashRole')}</span></label></div>183        <div className="flex gap-2"><div className="relative min-w-0 flex-1"><input id="qa-fund-cash" className={`${inputCls} pr-16 text-lg`} inputMode="decimal" placeholder="0" value={cashAmt} autoComplete="off" onChange={(e) => { setTouched(true); setCashAmt(e.target.value.replace(',', '.').replace(/[^0-9.]/g, '')); }} /><span className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-[13px] text-mute">{a.cash}</span></div>184          <Btn kind="soft" disabled={maxCash === 0n} onClick={() => { setTouched(true); setCashAmt(fromUnits(maxCash, cashDec)); }}>{t('max')}</Btn></div>185        <p className="mt-1.5 text-[12.5px] text-mute">{t('qaFundMainHas', { amount: trimAmt(fromUnits(mainCash, cashDec), 6), sym: a.cash, net: a.networkName })}{capCash ? ` ${t('qaFundLimit', { per: trimAmt(capCash.perD, 6), day: trimAmt(capCash.dayD, 6), left: trimAmt(capCash.leftD, 6), sym: a.cash })}` : ''}</p>186        {cashTooMuch ? <div className="mt-2"><Notice tone="danger">{t('qaFundErrBalance', { sym: a.cash, net: a.networkName })}</Notice></div>187          : cashOverPer ? <div className="mt-2"><Notice tone="danger">{t('qaFundErrPerTx', { max: trimAmt(capCash!.perD, 6), sym: a.cash })}</Notice></div>188          : cashOverDay ? <div className="mt-2"><Notice tone="danger">{t('qaFundErrDaily', { left: trimAmt(capCash!.leftD, 6), sym: a.cash })}</Notice></div> : null}189        {(mainCash === 0n || cashTooMuch) && <div className="mt-2 space-y-2"><Notice tone="warn">{t('qaFundNoCash', { sym: a.cash, net: a.networkName })}{usdcElsewhere.length > 0 && <> {t('qaFundCashElsewhere')}190          <ul className="mt-1.5 space-y-0.5">{usdcElsewhere.map((b) => <li key={b.network} className="flex items-center gap-2"><NetDot net={b.network} /><span>{trimAmt(b.display ?? '0', 6)} USDC · {b.networkName}</span></li>)}</ul></>}</Notice>191          {usdcElsewhere.filter((b) => canBridge(b.network)).map((b) => <Btn key={b.network} kind="soft" full onClick={() => toSwap(b.network)}><Icon n="swap" className="h-4 w-4 shrink-0" />{t('qaFundBridge', { from: b.networkName, to: a.networkName })}</Btn>)}192          {usdcElsewhere.length > 0 && !usdcElsewhere.some((b) => canBridge(b.network)) && <p className="text-[12.5px] text-mute">{t('qaFundNoBridge', { sym: a.cash, net: a.networkName })}</p>}</div>}193      </section>194195      <section className="mt-5" aria-label={natSym}>196        <div className="mb-1.5 flex items-baseline justify-between gap-3"><label htmlFor="qa-fund-gas" className="text-[14px] font-semibold">{natSym} <span className="font-normal text-mute">· {t('qaFundGasRole')}</span></label></div>197        <div className="flex gap-2"><div className="relative min-w-0 flex-1"><input id="qa-fund-gas" className={`${inputCls} pr-16 text-lg`} inputMode="decimal" placeholder="0" value={gasAmt} autoComplete="off" onChange={(e) => { setTouched(true); setGasAmt(e.target.value.replace(',', '.').replace(/[^0-9.]/g, '')); }} /><span className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-[13px] text-mute">{natSym}</span></div>198          {missing > 0n && <Btn kind="soft" onClick={() => { setTouched(true); setGasAmt(fromUnits(missing, natDec)); }}>{t('qaFundGasSuggest', { amount: trimAmt(fromUnits(missing, natDec), 6) })}</Btn>}</div>199        <p className="mt-1.5 text-[12.5px] text-mute">{reserve === null ? t('qaFundGasUnknown') : missing > 0n ? t('qaFundGasMissing', { reserve: trimAmt(gas!.reserve, 6), held: trimAmt(fromUnits(vNat, natDec), 6), missing: trimAmt(fromUnits(missing, natDec), 6), sym: natSym }) : t('qaFundGasOk', { reserve: trimAmt(gas!.reserve, 6), sym: natSym })} {t('qaFundMainHas', { amount: trimAmt(fromUnits(mainNat, natDec), 6), sym: natSym, net: a.networkName })}</p>200        {gasOverPer && <div className="mt-2"><Notice tone="danger">{t('qaFundErrPerTx', { max: trimAmt(capNat!.perD, 6), sym: natSym })}</Notice></div>}201        {(gasTooMuch || (mainNat === 0n && (cashU > 0n || missing > 0n))) && <div className="mt-2 space-y-2"><Notice tone="warn">{t('qaFundNoNative', { native: natSym, net: a.networkName })}{myAddr && <span className="mt-1.5 block"><Addr value={myAddr} /></span>}</Notice>202          <div className="grid grid-cols-2 gap-2">{myAddr && <CopyBtn text={myAddr} full />}<Btn kind="soft" onClick={() => nav({ tab: 'receive', network: a.network })}><Icon n="down" className="h-4 w-4" />{t('receive')}</Btn></div></div>}203      </section>204205      <dl className="mt-5 space-y-1.5 rounded-2xl bg-elev/70 p-3.5 text-[13.5px]">206        {row(t('qaFundVaultNow'), `${trimAmt(fromUnits(vCash, cashDec), 6)} ${a.cash} · ${trimAmt(fromUnits(vNat, natDec), 6)} ${natSym}`)}207        {row(t('qaFundVaultAfter'), `${trimAmt(fromUnits(vCash + cashU, cashDec), 6)} ${a.cash} · ${trimAmt(fromUnits(vNat + gasU, natDec), 6)} ${natSym}`)}208        {row(t('qaFundPlanned'), `${a.capitalUsdc} ${a.cash}`)}209      </dl>210      {ov.data?.signer.halted && <div className="mt-3"><Notice tone="danger">{t('haltedBanner')}</Notice></div>}211      {ov.data?.broadcast === false && <div className="mt-3"><Notice tone="warn">{t('qaFundTestBanner')}</Notice></div>}212      <p className="mt-3 text-[12.5px] text-mute">{cashU > 0n && gasU > 0n ? t('qaFundTwoSteps', { net: a.networkName }) : t('qaFundOneStep')}</p>213      <div className="mt-4 space-y-2.5">214        <Btn full busy={busy} disabled={nothing || blocked} onClick={start}><Icon n="key" />{nothing ? t('qaFundEnter') : t('qaFundGo', { what: [cashU > 0n ? `${trimAmt(fromUnits(cashU, cashDec), 6)} ${a.cash}` : '', gasU > 0n ? `${trimAmt(fromUnits(gasU, natDec), 6)} ${natSym}` : ''].filter(Boolean).join(' + ') })}</Btn>215        <Btn full kind="ghost" onClick={onClose}>{t('cancel')}</Btn>216      </div></>}217  </Sheet>;218}219220// ── withdrawing from the agent's wallet on its own network ───────────────────────────────────────────────────────────────221export function WithdrawSheet({ a, onClose }: { a: FundAgent; onClose: () => void }) {222  const t = useT(); const qc = useQueryClient();223  const ov = useQuery({ queryKey: ['security'], queryFn: () => get<Overview>('/security/overview'), staleTime: 15_000 });224  const [busy, setBusy] = useState(''), [res, setRes] = useState<Record<string, { ok: boolean; text: string; explorer?: string }>>({});225  const live = a.state === 'LIVE', rows = (a.vault ?? []).filter((x) => x.amount !== '0');226  const native = (a.vault ?? []).find((x) => x.asset === 'native'), natSym = native?.assetSymbol ?? '';227  // the native coin pays the fee of every other withdrawal: it leaves last228  const ordered = [...rows].sort((x, y) => Number(x.asset === 'native') - Number(y.asset === 'native'));229  const go = async (x: VaultRow) => { setBusy(x.asset);230    try { const r = await post<{ amount: string; symbol: string; explorer: string }>(`/agents/${a.id}/withdraw`, { asset: x.asset });231      setRes((m) => ({ ...m, [x.asset]: { ok: true, text: ov.data?.broadcast === false ? t('qaWdTest', { amount: trimAmt(r.amount, 8), sym: r.symbol }) : t('qaWdSent', { amount: trimAmt(r.amount, 8), sym: r.symbol }), explorer: ov.data?.broadcast === false ? undefined : r.explorer } }));232      await qc.invalidateQueries({ queryKey: ['agent', a.id] }); void qc.invalidateQueries({ queryKey: ['portfolio'] });233    } catch (e) { setRes((m) => ({ ...m, [x.asset]: { ok: false, text: (e as { code?: string }).code === 'bad_state' ? t('qaWdLive') : fundErr(t, e, { sym: x.assetSymbol, net: a.networkName, native: natSym }) } })); }234    finally { setBusy(''); } };235  return <Sheet title={t('qaWdTitle')} onClose={onClose}>236    <NetBanner net={a.network}>{t('qaWdFrom', { name: a.name, net: a.networkName })}</NetBanner>237    <p className="mt-3 text-[13.5px] text-mute">{t('qaWdIntro', { native: natSym })}</p>238    {live && <div className="mt-3"><Notice tone="warn">{t('qaWdLive')}</Notice></div>}239    {ordered.length === 0 ? <p className="mt-4 text-[14px] text-mute">{t('qaWdEmpty')}</p> : <ul className="mt-4 space-y-2.5">{ordered.map((x) => <li key={x.asset} className="rounded-2xl border border-line p-3">240      <div className="flex items-center gap-3"><Coin sym={x.assetSymbol} net={a.network} size={36} />241        <span className="min-w-0 flex-1"><span className="block text-[15px] font-semibold">{trimAmt(x.display, 8)} {x.assetSymbol}</span><span className="block truncate text-[12.5px] text-mute">{x.name}{x.asset === 'native' ? ` · ${t('qaWdNativeLast')}` : ''}</span></span>242        <Btn kind="soft" disabled={live || (!!busy && busy !== x.asset)} busy={busy === x.asset} onClick={() => void go(x)}>{t('agWithdraw')}</Btn></div>243      {res[x.asset] && <div className="mt-2.5"><Notice tone={res[x.asset]!.ok ? 'ok' : 'danger'}>{res[x.asset]!.text}{res[x.asset]!.explorer && <a className="mt-1 flex min-h-11 items-center gap-1.5 text-blue" href={res[x.asset]!.explorer} target="_blank" rel="noreferrer noopener"><Icon n="ext" className="h-4 w-4" />{t('viewExplorer')}</a>}</Notice></div>}244    </li>)}</ul>}245    <Btn full kind="ghost" className="mt-4" onClick={onClose}>{t('qaClose')}</Btn>246  </Sheet>;247}248249// ── funds the vault's address holds on networks the agent does not trade on ──────────────────────────────────────────────250export function ElsewhereFunds({ a }: { a: FundAgent }) {251  const t = useT(); const qc = useQueryClient();252  const q = useQuery({ queryKey: ['agent-elsewhere', a.id], queryFn: () => get<{ holdings: Elsewhere[] }>(`/agents/${a.id}/elsewhere`), refetchInterval: 30_000 });253  const pf = usePortfolio(), nets = useNetworks();254  const ov = useQuery({ queryKey: ['security'], queryFn: () => get<Overview>('/security/overview'), staleTime: 15_000 });255  const [busy, setBusy] = useState(''), [msg, setMsg] = useState<Record<string, { ok: boolean; text: string }>>({});256  const [top, setTop] = useState<Record<string, { id: string; state: string; error: string | null }>>({});257  const pending = Object.entries(top).find(([, v]) => !OK.includes(v.state) && !BAD.includes(v.state) && !TEST_NOT_SENT.test(v.error ?? ''));258  const live = useQuery({ queryKey: ['proposal', pending?.[1].id], enabled: !!pending, refetchInterval: 3000, queryFn: () => get<Proposal>(`/transfers/proposals/${pending![1].id}`) });259  useEffect(() => { const p = live.data; if (!p || !pending || p.id !== pending[1].id) return; if (p.state === pending[1].state && p.error === pending[1].error) return;260    setTop((m) => ({ ...m, [pending[0]]: { id: p.id, state: p.state, error: p.error } })); if (OK.includes(p.state)) void qc.invalidateQueries({ queryKey: ['agent-elsewhere', a.id] });261  }, [live.data]); // eslint-disable-line react-hooks/exhaustive-deps262  const list = q.data?.holdings ?? [];263  const byNet = useMemo(() => { const m = new Map<string, Elsewhere[]>(); for (const h of list) m.set(h.network, [...(m.get(h.network) ?? []), h]);264    // tokens first: the native coin pays their withdrawal fee and leaves last265    return [...m.entries()].map(([k, v]) => [k, [...v].sort((x, y) => Number(x.asset === 'native') - Number(y.asset === 'native'))] as [string, Elsewhere[]]); }, [list]);266  if (list.length === 0) return null;267  const key = (h: Elsewhere) => `${h.network}:${h.asset}`;268  const withdraw = async (h: Elsewhere) => { setBusy(key(h));269    try { const r = await post<{ amount: string; symbol: string }>(`/agents/${a.id}/withdraw`, { asset: h.asset, network: h.network });270      setMsg((m) => ({ ...m, [key(h)]: { ok: true, text: ov.data?.broadcast === false ? t('qaWdTest', { amount: trimAmt(r.amount, 8), sym: r.symbol }) : t('qaWdSent', { amount: trimAmt(r.amount, 8), sym: r.symbol }) } }));271      await qc.invalidateQueries({ queryKey: ['agent-elsewhere', a.id] }); void qc.invalidateQueries({ queryKey: ['portfolio'] });272    } catch (e) { setMsg((m) => ({ ...m, [key(h)]: { ok: false, text: fundErr(t, e, { sym: h.symbol, net: h.networkName, native: h.gas }) } })); } finally { setBusy(''); } };273  return <div className="mt-4" data-testid="agent-elsewhere">274    <div className="mb-2 text-[15px] font-semibold">{t('qaElseTitle')}</div>275    <Notice tone="warn">{t('qaElseIntro', { network: a.networkName })}</Notice>276    <div className="mt-3 space-y-3">{byNet.map(([net, hs]) => { const h0 = hs[0]!, n = nets.data?.find((x) => x.id === net), dec = n?.native.decimals ?? 18;277      const tokens = hs.filter((h) => h.asset !== 'native'), lacking = tokens.length > 0 && (h0.gasEnough === false || (h0.gasEnough == null && !h0.hasGas));278      const need = h0.gasNeed ? toUnits(h0.gasNeed, dec) : 0n, held = toUnits(h0.gasHeld ?? '0', dec);279      // enough for every token of this network, with a margin for a fee that moves; rounded up280      const send = ceilTo((need * BigInt(tokens.length) * 3n) / 2n - held, dec, 6), sendD = fromUnits(send > 0n ? send : 0n, dec);281      const mainNat = BigInt(pf.data?.balances.find((b) => b.network === net && b.asset === 'native')?.amount ?? '0');282      const tp = top[net], tpTest = !!tp && TEST_NOT_SENT.test(tp.error ?? '');283      const topUp = async () => { setBusy(`gas${net}`); setMsg((m) => ({ ...m, [`gas${net}`]: { ok: true, text: '' } }));284        try { const p = await sendTransfer({ network: net, asset: 'native', to: a.vaultAddress, amount: sendD, note: `Agent ${a.name}`, idem: crypto.randomUUID() }); setTop((m) => ({ ...m, [net]: { id: p.id, state: p.state, error: p.error } })); }285        catch (e) { setMsg((m) => ({ ...m, [`gas${net}`]: { ok: false, text: fundErr(t, e, { sym: h0.gas, net: h0.networkName, native: h0.gas }) } })); } finally { setBusy(''); } };286      return <section key={net} className="rounded-2xl border border-line p-3.5" aria-label={h0.networkName}>287        <div className="mb-2 flex items-center gap-2 text-[14px] font-semibold"><NetDot net={net} />{h0.networkName}</div>288        <ul className="space-y-2.5">{hs.map((h) => <li key={key(h)}>289          <div className="flex items-center gap-3"><Coin sym={h.symbol} net={h.network} size={32} /><span className="min-w-0 flex-1 text-[15px] font-semibold">{trimAmt(h.display, 8)} {h.symbol}{h.asset === 'native' && <span className="block text-[12px] font-normal text-mute">{t('qaWdNativeLast')}</span>}</span>290            <Btn kind="soft" busy={busy === key(h)} disabled={(!!busy && busy !== key(h)) || (h.asset !== 'native' && lacking)} onClick={() => void withdraw(h)}>{t('agWithdraw')}</Btn></div>291          {msg[key(h)]?.text && <div className="mt-2"><Notice tone={msg[key(h)]!.ok ? 'ok' : 'danger'}>{msg[key(h)]!.text}</Notice></div>}</li>)}</ul>292        {lacking && <div className="mt-3 space-y-2"><Notice tone="info">{h0.gasNeed ? t('qaElseNeedGas', { native: h0.gas, net: h0.networkName, need: trimAmt(h0.gasNeed, 8), held: trimAmt(h0.gasHeld ?? '0', 8) }) : t('agElsewhereGas', { gas: h0.gas })}</Notice>293          {tp && !BAD.includes(tp.state) && !tpTest ? <p className="flex items-center gap-2 text-[13px] text-mute"><Icon n="spin" className="h-4 w-4" />{t('qaElseGasSent', { native: h0.gas })}</p>294            : tpTest ? <Notice tone="warn">{t('qaFundTestMode')}</Notice>295            : h0.gasNeed && mainNat > send ? <Btn full kind="soft" busy={busy === `gas${net}`} disabled={!!busy && busy !== `gas${net}`} onClick={() => void topUp()}><Icon n="key" className="h-4 w-4 shrink-0" />{t('qaElseSendGas', { amount: trimAmt(sendD, 6), native: h0.gas, net: h0.networkName })}</Btn>296            : <p className="text-[12.5px] text-mute">{t('qaElseNoMainGas', { native: h0.gas, net: h0.networkName })}</p>}297          {msg[`gas${net}`]?.text && <Notice tone="danger">{msg[`gas${net}`]!.text}</Notice>}</div>}298      </section>; })}</div>299  </div>;300}301302// ── readiness checklist ──────────────────────────────────────────────────────────────────────────────────────────────────303export function AgentChecklist({ a, gas, approved, busy, onFund, onStrategy, onPaper, onLive }: { a: FundAgent; gas: GasInfo | null; approved: boolean; busy: string; onFund: () => void; onStrategy: () => void; onPaper: () => void; onLive: () => void }) {304  const t = useT(); const { locale } = useContext(I18n);305  const nat = (a.vault ?? []).find((x) => x.asset === 'native'), natSym = nat?.assetSymbol ?? '', planned = Number(a.capitalUsdc), reserveN = gas ? Number(gas.reserve) : 0;306  // capital = everything the strategy can use: the fee reserve is not part of it, even when the agent trades the native coin307  const usd = (a.vault ?? []).reduce((s, x) => { if (x.role === 'gas' || x.usd === null) return s; if (x.asset !== 'native') return s + x.usd; const d = Number(x.display); return s + (d > 0 ? (x.usd * Math.max(0, d - reserveN)) / d : 0); }, 0);308  const fundsOk = a.vault !== null && usd >= planned * 0.98, fundsSome = usd > 0;309  const gasOk = !!gas && !!nat && Number(nat.display) >= Number(gas.reserve);310  const live = a.state === 'LIVE', paper = a.state === 'PAPER_TRADING';311  const f = (v: number) => new Intl.NumberFormat(locale === 'fr' ? 'fr-CA' : 'en-CA', { maximumFractionDigits: 2 }).format(v);312  const line = (ok: boolean | 'part', label: string, detail: string, id: string) => <li className="flex items-start gap-3" data-check={id} data-ok={String(ok)}>313    <span className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full ${ok === true ? 'bg-emerald/15 text-emerald' : ok === 'part' ? 'bg-warn/15 text-warn' : 'bg-elev text-mute'}`}><Icon n={ok === true ? 'check' : ok === 'part' ? 'clock' : 'x'} className="h-3.5 w-3.5" /></span>314    <span className="min-w-0"><span className="block text-[14.5px] font-medium">{label}<span className="sr-only"> — {ok === true ? t('qaChkYes') : t('qaChkNo')}</span></span><span className="block text-[12.5px] text-mute">{detail}</span></span></li>;315  const next = live ? null : !fundsOk || (!gasOk && fundsSome && approved) ? { label: fundingInProgress(a.id) ? t('qaFundResume') : !fundsOk ? t('qaFundOpen') : t('qaChkAddGas', { native: natSym }), on: onFund, icon: 'up', id: 'fund' }316    : !approved ? { label: t('qaChkPickStrategy'), on: onStrategy, icon: 'list', id: 'strategy' }317    : !paper ? { label: t('agStartPaper'), on: onPaper, icon: 'clock', id: 'paper' }318    : gasOk ? { label: t('agGoLive'), on: onLive, icon: 'key', id: 'live' } : { label: t('qaChkAddGas', { native: natSym }), on: onFund, icon: 'up', id: 'fund' };319  return <section className="card p-5" aria-label={t('qaChkTitle')} data-testid="agent-checklist">320    <h2 className="mb-3 text-[17px] font-semibold tracking-tight">{t('qaChkTitle')}</h2>321    <ul className="space-y-3">322      {line(fundsOk ? true : fundsSome ? 'part' : false, t('qaChkFunds'), a.vault === null ? t('qaChkUnknown') : t('qaChkFundsDetail', { have: f(usd), planned: f(planned), cash: a.cash, net: a.networkName }), 'funds')}323      {line(gasOk, t('qaChkGas', { native: natSym }), !gas ? t('qaFundGasUnknown') : t('qaChkGasDetail', { have: trimAmt(nat?.display ?? '0', 6), need: trimAmt(gas.reserve, 6), native: natSym }), 'gas')}324      {line(approved, t('qaChkStrategy'), approved ? t('qaChkStrategyOk') : t('agNeedsStrategy'), 'strategy')}325      {line(live || paper ? true : false, t('qaChkMode'), live ? t('qaChkModeLive') : paper ? t('qaChkModePaper') : t('qaChkModeOff'), 'mode')}326    </ul>327    {next && <Btn full className="mt-4" busy={busy === next.id} onClick={next.on} data-next={next.id}><Icon n={next.icon} className="h-4 w-4" />{next.label}</Btn>}328  </section>;329}330331/** Notice for the Send screen: the typed recipient is one of the owner's agent wallets. */332export function AgentRecipientNotice({ to, network, networkName }: { to: string; network: string; networkName: string }) {333  const t = useT();334  const q = useQuery({ queryKey: ['agents'], queryFn: () => get<{ agents: { id: string; name: string; network: string; networkName: string; vaultAddress: string; cash: string }[] }>('/agents'), staleTime: 60_000, enabled: /^0x[0-9a-fA-F]{40}$/.test(to) });335  const ag = q.data?.agents.find((x) => x.vaultAddress.toLowerCase() === to.toLowerCase());336  if (!ag) return null;337  return ag.network === network ? <Notice tone="info">{t('qaSendAgent', { name: ag.name, net: ag.networkName })}</Notice>338    : <Notice tone="danger"><b>{t('qaSendAgentWrongNet', { name: ag.name, agentNet: ag.networkName, net: networkName })}</b> {t('qaSendAgentWrongNetHow', { agentNet: ag.networkName })}</Notice>;339}340
Files main
Menu

Theme