feat(fiat): three-step deposit + symmetric withdraw panel

DepositPanel was a 2-step flow (currency pill + combined issuer·chain
dropdown); withdraw was a one-step mini inside the Hold card. Both now
pick 1. Currency → 2. Stablecoin → 3. Network as three separate
dropdowns — same shape as Anthemus/DepositPanel, and the shape a user
expects when moving money across chains.

- UniformFiat/DepositPanel.tsx: split cellCode into issuer + chain state
  with cascading defaults.
- UniformFiat/WithdrawPanel.tsx: NEW, symmetric with Deposit. Only
  integrated rails are pickable so the user cannot queue an
  unbroadcastable withdraw.
- UniformFiat/UniformFiatScreen.tsx: added 'withdraw' to the Mode union
  and a Withdraw tab; removed the nested WithdrawMini (replaced by a
  one-line pointer to the Withdraw tab from the Hold detail).
This commit is contained in:
Local Dev 2026-09-06 18:22:55 +02:00
parent 78c5bd460b
commit 503f45809e
3 changed files with 195 additions and 65 deletions

View file

@ -1,10 +1,17 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
type Ccy, type FiatState, depositableCells, priceOf, creditedFor, inBand,
applyDeposit, fmt6, parseAmt, rateStr,
} from './engine';
// DEPOSIT — lock a stablecoin on its source chain, receive the unified currency on BCH.
// THREE-STEP FLOW (aligned with Anthemus/DepositPanel + WithdrawPanel):
// 1. Currency (USD/EUR — the receiving asset on BCH)
// 2. Stablecoin (the issuer, e.g. USDC/USDT/PYUSD/DAI/EURC)
// 3. Network (the chain the stablecoin is being deposited FROM)
// The user picks each explicitly — the (issuer × chain) matrix is a reserve cell,
// never a trading pair; the split makes the picking honest.
//
// THE DISCLOSURE IS MANDATORY (contract: constraint §4 + the honest-label rule): before
// confirming, the attested market price and the credited amount are shown explicitly —
// "USDC is trading at $0.9987 — you will receive 9.98 USD." Never par, never implied.
@ -15,19 +22,34 @@ export function DepositPanel({ state, onApply }: {
}) {
const [ccy, setCcy] = useState<Ccy>('USD');
const cells = depositableCells(ccy);
const [cellCode, setCellCode] = useState(cells[0].code);
// Distinct issuers (stablecoins) for the current currency, e.g. USD → [USDT, USDC, PYUSD, DAI].
const issuers = useMemo(() => [...new Set(cells.map((c) => c.issuer))], [cells]);
const [issuer, setIssuer] = useState<string>(issuers[0] ?? '');
// Chains available for the current (currency, issuer) pair.
const chainsForIssuer = useMemo(
() => cells.filter((c) => c.issuer === issuer).map((c) => c.chain),
[cells, issuer],
);
const [chain, setChain] = useState<string>(chainsForIssuer[0] ?? '');
const [amountStr, setAmountStr] = useState('');
const cell = cells.find((c) => c.code === cellCode) ?? cells[0];
// Cascade defaults: keep selections valid when currency or issuer changes.
useEffect(() => {
if (!issuers.includes(issuer)) setIssuer(issuers[0] ?? '');
}, [issuers, issuer]);
useEffect(() => {
if (!chainsForIssuer.includes(chain)) setChain(chainsForIssuer[0] ?? '');
}, [chainsForIssuer, chain]);
const cell = cells.find((c) => c.issuer === issuer && c.chain === chain) ?? cells[0];
const amount = parseAmt(amountStr);
const credited = useMemo(() => creditedFor(cell.issuer, amount), [cell, amount]);
const price = priceOf(cell.issuer);
const sym = ccy === 'USD' ? '$' : '€';
const pickCcy = (c: Ccy) => {
setCcy(c);
setCellCode(depositableCells(c)[0].code);
};
const pickCcy = (c: Ccy) => setCcy(c);
const doDeposit = () => {
if (credited <= 0n) return;
@ -46,25 +68,39 @@ export function DepositPanel({ state, onApply }: {
</div>
<div className="uf-field">
{/* Step 1 — the currency you want to receive on BCH. */}
<div className="uf-field-top">
<span>Receive</span>
<span>1. Currency</span>
<span className="uf-ccy-pick">
{(['USD', 'EUR'] as Ccy[]).map((c) => (
<button key={c} className={`uf-pill${ccy === c ? ' on' : ''}`} onClick={() => pickCcy(c)}>{c}</button>
))}
</span>
</div>
{/* Step 2 — the specific stablecoin (issuer) that settles into that currency. */}
<div className="uf-field-top" style={{ marginTop: 6 }}>
<span>You deposit</span>
<select className="uf-select" value={cellCode} onChange={(e) => setCellCode(e.target.value)}>
{cells.map((c) => (
<option key={c.code} value={c.code}>
{c.issuer} · {c.chain}{c.status === 'vetting' ? ' (vetting)' : ''}
</option>
<span>2. Stablecoin</span>
<select className="uf-select" value={issuer} onChange={(e) => setIssuer(e.target.value)}>
{issuers.map((iss) => (
<option key={iss} value={iss}>{iss}</option>
))}
</select>
</div>
<div className="uf-field-row">
{/* Step 3 — the source chain the stablecoin is on. */}
<div className="uf-field-top" style={{ marginTop: 6 }}>
<span>3. Network</span>
<select className="uf-select" value={chain} onChange={(e) => setChain(e.target.value)}>
{chainsForIssuer.map((ch) => {
const c = cells.find((x) => x.issuer === issuer && x.chain === ch)!;
return (
<option key={ch} value={ch}>
{ch}{c.status === 'vetting' ? ' (vetting)' : ''}
</option>
);
})}
</select>
</div>
<div className="uf-field-row" style={{ marginTop: 6 }}>
<input
className="uf-amount"
inputMode="decimal"

View file

@ -1,9 +1,10 @@
import { useState } from 'react';
import {
type Ccy, type FiatState, CCY_META, CELLS, loadState, saveState,
headroom, fmt6, parseAmt, applyWithdraw, depositableCells,
headroom, fmt6,
} from './engine';
import { DepositPanel } from './DepositPanel';
import { WithdrawPanel } from './WithdrawPanel';
import { FiatSwapPanel } from './FiatSwapPanel';
import './uniform.css';
@ -13,7 +14,7 @@ import './uniform.css';
// MANDATORY oracle-price disclosure; conversion copy is always "Swap"; a routed DEX leg is a
// normal fill. The detail card carries the honest label — never "$1 guaranteed".
type Mode = 'hold' | 'deposit' | 'swap';
type Mode = 'hold' | 'deposit' | 'withdraw' | 'swap';
const HONEST_LABEL: Record<Ccy, string> = {
USD: 'A dollar — diversified, transparent, on-chain, resilient to any single issuer or chain failing, and it lives on BCH.',
@ -45,12 +46,15 @@ export function UniformFiatScreen({ embedded }: { embedded?: boolean } = {}) {
<div className="gb-toggle">
<button className={`gb${mode === 'hold' ? ' on' : ''}`} onClick={() => { setMode('hold'); setNote(null); }}>Hold</button>
<button className={`gb${mode === 'deposit' ? ' on' : ''}`} onClick={() => { setMode('deposit'); setNote(null); }}>Deposit</button>
<button className={`gb${mode === 'withdraw' ? ' on' : ''}`} onClick={() => { setMode('withdraw'); setNote(null); }}>Withdraw</button>
<button className={`gb${mode === 'swap' ? ' on' : ''}`} onClick={() => { setMode('swap'); setNote(null); }}>Swap</button>
</div>
</div>
{mode === 'deposit' ? (
<DepositPanel state={state} onApply={apply} />
) : mode === 'withdraw' ? (
<WithdrawPanel state={state} onApply={apply} />
) : mode === 'swap' ? (
<FiatSwapPanel state={state} onApply={apply} />
) : (
@ -124,57 +128,13 @@ function CurrencyRow({ ccy, state, open, onToggle, onApply }: {
))}
</div>
<WithdrawMini ccy={ccy} state={state} onApply={onApply} />
<div className="hint" style={{ marginTop: 8 }}>
To move {ccy} off BCH, use the <b>Withdraw</b> tab above — same three-step
flow as Deposit (currency → stablecoin → network).
</div>
</div>
)}
</div>
);
}
// ── withdraw to a source chain (pro-rata; 1:1 when fully reserved) ─────────────
function WithdrawMini({ ccy, state, onApply }: {
ccy: Ccy;
state: FiatState;
onApply: (next: FiatState, note: string) => void;
}) {
const cells = depositableCells(ccy).filter((c) => c.status === 'integrated');
const [cellCode, setCellCode] = useState(cells[0]?.code ?? '');
const [amountStr, setAmountStr] = useState('');
if (cells.length === 0) {
return <div className="hint" style={{ marginTop: 10 }}>Withdrawals open when this currency's first rail is integrated.</div>;
}
const cell = cells.find((c) => c.code === cellCode) ?? cells[0];
const amount = parseAmt(amountStr);
const ok = amount > 0n && amount <= state.balances[ccy];
const doWithdraw = () => {
if (!ok) return;
const { next, payout } = applyWithdraw(state, ccy, amount);
onApply(next, `Withdrew ${fmt6(amount)} ${ccy} → ${fmt6(payout)} ${cell.issuer} released on ${cell.chain}`);
setAmountStr('');
};
return (
<div className="uf-withdraw">
<div className="uf-field-top">
<span>Withdraw to</span>
<select className="uf-select" value={cellCode} onChange={(e) => setCellCode(e.target.value)}>
{cells.map((c) => <option key={c.code} value={c.code}>{c.issuer} · {c.chain}</option>)}
</select>
</div>
<div className="uf-field-row" style={{ marginTop: 4 }}>
<input
className="uf-amount sm"
inputMode="decimal"
placeholder="0.00"
value={amountStr}
onChange={(e) => setAmountStr(e.target.value.replace(/[^\d.]/g, ''))}
/>
<button className="uf-cta sm" disabled={!ok} onClick={doWithdraw}>Withdraw</button>
</div>
<div className="hint" style={{ marginTop: 6 }}>
Pays pro-rata from the shared reserve — 1:1 while fully reserved (it is now).
</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
import { useEffect, useMemo, useState } from 'react';
import {
type Ccy, type FiatState, depositableCells, applyWithdraw, fmt6, parseAmt,
} from './engine';
// WITHDRAW — burn the unified currency on BCH, release the stablecoin on its
// source chain. THREE-STEP FLOW, symmetric with DepositPanel:
// 1. Currency (USD/EUR — the balance you're withdrawing FROM on BCH)
// 2. Stablecoin (the issuer you want to receive on a foreign chain)
// 3. Network (the chain the stablecoin is released on)
// Only integrated rails are selectable (vetting/candidate rails are visible in
// Hold's transparency table but cannot yet be picked here — they'd fail on
// broadcast). Payout is pro-rata from the shared reserve; 1:1 while fully
// reserved (it is now).
export function WithdrawPanel({ state, onApply }: {
state: FiatState;
onApply: (next: FiatState, note: string) => void;
}) {
const [ccy, setCcy] = useState<Ccy>('USD');
// Only integrated cells are pickable — sending USDC on Arbitrum today isn't
// wired; showing it here would let the user queue an unbroadcastable action.
const cells = useMemo(
() => depositableCells(ccy).filter((c) => c.status === 'integrated'),
[ccy],
);
const issuers = useMemo(() => [...new Set(cells.map((c) => c.issuer))], [cells]);
const [issuer, setIssuer] = useState<string>(issuers[0] ?? '');
const chainsForIssuer = useMemo(
() => cells.filter((c) => c.issuer === issuer).map((c) => c.chain),
[cells, issuer],
);
const [chain, setChain] = useState<string>(chainsForIssuer[0] ?? '');
const [amountStr, setAmountStr] = useState('');
useEffect(() => {
if (!issuers.includes(issuer)) setIssuer(issuers[0] ?? '');
}, [issuers, issuer]);
useEffect(() => {
if (!chainsForIssuer.includes(chain)) setChain(chainsForIssuer[0] ?? '');
}, [chainsForIssuer, chain]);
const amount = parseAmt(amountStr);
const balance = state.balances[ccy];
const ok = amount > 0n && amount <= balance && !!chain;
const cell = cells.find((c) => c.issuer === issuer && c.chain === chain);
const doWithdraw = () => {
if (!ok || !cell) return;
const { next, payout } = applyWithdraw(state, ccy, amount);
onApply(
next,
`Withdrew ${fmt6(amount)} ${ccy} → ${fmt6(payout)} ${cell.issuer} released on ${cell.chain}`,
);
setAmountStr('');
};
return (
<div className="uf-card">
<div className="uf-card-head">
<span className="label-mono">Withdraw → stablecoin on source chain</span>
<span className="uf-tag">pro-rata · 1:1 while fully reserved</span>
</div>
{cells.length === 0 ? (
<div className="hint" style={{ marginTop: 10 }}>
Withdrawals for {ccy} open when the first rail is integrated.
</div>
) : (
<>
<div className="uf-field">
<div className="uf-field-top">
<span>1. Currency</span>
<span className="uf-ccy-pick">
{(['USD', 'EUR'] as Ccy[]).map((c) => (
<button key={c} className={`uf-pill${ccy === c ? ' on' : ''}`} onClick={() => setCcy(c)}>{c}</button>
))}
</span>
</div>
<div className="uf-field-top" style={{ marginTop: 6 }}>
<span>2. Stablecoin</span>
<select className="uf-select" value={issuer} onChange={(e) => setIssuer(e.target.value)}>
{issuers.map((iss) => (
<option key={iss} value={iss}>{iss}</option>
))}
</select>
</div>
<div className="uf-field-top" style={{ marginTop: 6 }}>
<span>3. Network</span>
<select className="uf-select" value={chain} onChange={(e) => setChain(e.target.value)}>
{chainsForIssuer.map((ch) => (
<option key={ch} value={ch}>{ch}</option>
))}
</select>
</div>
<div className="uf-field-row" style={{ marginTop: 6 }}>
<input
className="uf-amount"
inputMode="decimal"
placeholder="0.00"
value={amountStr}
onChange={(e) => setAmountStr(e.target.value.replace(/[^\d.]/g, ''))}
/>
<span className="uf-asset">{ccy}</span>
</div>
</div>
<div className={`uf-disclosure${amount > 0n ? '' : ' idle'}`}>
Balance available: <b>{fmt6(balance)} {ccy}</b>.{' '}
{amount > 0n
? amount > balance
? <>you can withdraw at most <b>{fmt6(balance)} {ccy}</b>.</>
: cell
? <>you will receive <b>{fmt6(amount)} {cell.issuer}</b> on {cell.chain}.</>
: <>select a network.</>
: <>enter an amount to see the payout.</>}
</div>
<button className="uf-cta" disabled={!ok} onClick={doWithdraw}>
{ok && cell
? `Withdraw ${fmt6(amount)} ${ccy} → ${fmt6(amount)} ${cell.issuer}`
: 'Enter an amount'}
</button>
<div className="hint" style={{ marginTop: 10 }}>
The unified {ccy} burns on BCH; the payout is released from the shared reserve on the
selected network. Payout is pro-rata (1:1 today — the reserve is fully backed).
</div>
</>
)}
</div>
);
}