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:
parent
78c5bd460b
commit
503f45809e
3 changed files with 195 additions and 65 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
134
shells/app/src/screens/UniformFiat/WithdrawPanel.tsx
Normal file
134
shells/app/src/screens/UniformFiat/WithdrawPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue