SkateSpots/src/components/EscrowRequestDialog.tsx
SkateSpots b48f0e53ee feat(escrow): seller-advertised mint, preferred by the buyer
Finishes the escrow-mint work: advertiseEscrowKey now also publishes
cashu_mint (the seller's preferred mint from Wallet > Escrow) on kind 0,
documented in NIP.md. The buyer's EscrowRequestDialog reads the seller's
cashu_mint, includes it in the mint picker even if it is not in the buyer's
trust list, and prefers it (falling back to the buyer's preference, then
first trusted mint). Funds locked at a mint the seller actually uses are
the ones they can redeem. Still open: seller-side receive from a mint they
do not already have. New marketUi.escrowRequest.sellerMintHint (en/es).
2026-10-10 08:57:33 +07:00

214 lines
8.4 KiB
TypeScript

import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
import { useAuthor } from '@/hooks/useAuthor';
import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
import { useToast } from '@/hooks/useToast';
import { ESCROW_MIN_LOCK_DAYS, mintSupportsP2PK } from '@/lib/cashuEscrow';
import { savePendingEscrow, clearPendingEscrow } from '@/lib/pendingEscrow';
import { Loader2, Lock } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
/**
* Buyer: lock funds in Cashu escrow (P2PK to the seller, refundable after a
* deadline) and send the `31872` confirmation request carrying the token.
*
* Native-only (the Cashu wallet is Capacitor-gated). EXPERIMENTAL — the
* mint-touching path has not been verified on a device; start with small
* amounts.
*/
export function EscrowRequestDialog({
open,
onOpenChange,
listing,
sellerPubkey,
sellerName,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
listing: { pubkey: string; identifier: string };
sellerPubkey: string;
sellerName: string;
}) {
const { t } = useTranslation();
const escrow = useCashuEscrow();
const { prefs } = useEscrowPrefs();
const { publishRequest, isPending: publishing } = useTradeAttestationRequest();
const { toast } = useToast();
const [amount, setAmount] = useState('');
const [days, setDays] = useState(String(prefs.defaultLockDays));
const [busy, setBusy] = useState(false);
// Buyer-facing mint choice: default to their preference, else the first trusted
// mint. Shown explicitly so the buyer knows where the funds will be locked.
const [mintChoice, setMintChoice] = useState('');
const sellerAuthor = useAuthor(sellerPubkey);
const sellerMintRaw = (sellerAuthor.data?.metadata as Record<string, unknown> | undefined)?.cashu_mint;
const sellerMint =
typeof sellerMintRaw === 'string' && sellerMintRaw.trim() ? sellerMintRaw.trim() : undefined;
const mintOptions = escrow.mints;
// Include the seller's advertised mint even if it is not in our trust list, and
// prefer it — a mint the seller actually uses is one they can redeem from.
const mintChoices =
sellerMint && !mintOptions.some((m) => m.url === sellerMint)
? [...mintOptions, { url: sellerMint }]
: mintOptions;
const effectiveMint = mintChoice || sellerMint || prefs.preferredMintUrl || mintChoices[0]?.url || '';
const p2pkBlocked =
mintSupportsP2PK(mintChoices.find((m) => m.url === effectiveMint)?.info) === false;
const submit = async () => {
const amountSats = parseInt(amount, 10);
const lockDays = parseInt(days, 10);
if (!Number.isFinite(amountSats) || amountSats <= 0) {
toast({ title: t('marketUi.escrowRequest.enterAmount'), variant: 'destructive' });
return;
}
if (!Number.isFinite(lockDays) || lockDays < ESCROW_MIN_LOCK_DAYS) {
toast({ title: t('marketUi.escrowRequest.minLock', { days: ESCROW_MIN_LOCK_DAYS }), variant: 'destructive' });
return;
}
setBusy(true);
let mintedToken: string | undefined;
try {
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: effectiveMint || undefined });
mintedToken = token;
// Persist BEFORE publishing: if the request fails to send, the P2PK-locked
// funds stay recoverable from this device instead of being lost.
savePendingEscrow(listing, token, amountSats, lockDays);
await publishRequest({
listing,
attestor: sellerPubkey,
cashuToken: token,
note: t('marketUi.escrowRequest.requestNote', { amount: amountSats }),
});
clearPendingEscrow(listing);
toast({
title: t('marketUi.escrowRequest.created'),
description: t('marketUi.escrowRequest.createdBody', { amount: amountSats, days: lockDays, sellerName }),
});
onOpenChange(false);
} catch (e) {
toast({
title: t('marketUi.escrowRequest.createFailed'),
description: mintedToken
? t('marketUi.escrowRequest.lockedNotSent')
: e instanceof Error ? e.message : t('marketUi.escrowRequest.somethingWrong'),
variant: 'destructive',
});
} finally {
setBusy(false);
}
};
const pending = busy || publishing;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-[#ff6600]/40 bg-black sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<Lock className="h-5 w-5 text-[#ff6600]" /> {t('marketUi.escrowRequest.title')}
</DialogTitle>
<DialogDescription className="text-sm text-gray-400">
{t('marketUi.escrowRequest.description', { sellerName })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="escrow-amount" className="text-xs uppercase tracking-wide text-gray-400">
{t('marketUi.escrowRequest.amountLabel')}
</Label>
<Input
id="escrow-amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
inputMode="numeric"
placeholder={t('marketUi.offer.amountPlaceholder')}
className="h-11 border-gray-700 bg-gray-900 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="escrow-mint" className="text-xs uppercase tracking-wide text-gray-400">
{t('marketUi.escrowRequest.mintLabel')}
</Label>
<select
id="escrow-mint"
value={effectiveMint}
onChange={(e) => setMintChoice(e.target.value)}
className="h-11 w-full rounded-md border border-gray-700 bg-gray-900 px-3 text-sm text-white"
>
{mintChoices.length === 0 && (
<option value="">{t('marketUi.escrowRequest.noMints')}</option>
)}
{mintChoices.map((m) => (
<option key={m.url} value={m.url}>
{m.url.replace(/^https?:\/\//, '')}
</option>
))}
</select>
<p className="text-[11px] text-gray-500">{t('marketUi.escrowRequest.mintHint')}</p>
{sellerMint && (
<p className="text-[11px] text-[#ccff00]">
{t('marketUi.escrowRequest.sellerMintHint')}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="escrow-days" className="text-xs uppercase tracking-wide text-gray-400">
{t('marketUi.escrowRequest.daysLabel')}
</Label>
<Input
id="escrow-days"
value={days}
onChange={(e) => setDays(e.target.value)}
inputMode="numeric"
className="h-11 border-gray-700 bg-gray-900 text-white"
/>
<p className="text-[11px] text-gray-500">
{t('marketUi.escrowRequest.daysHint')}
</p>
</div>
{!escrow.available && (
<p className="text-xs text-amber-400">
{t('marketUi.escrowRequest.nativeOnly')}
</p>
)}
{p2pkBlocked && (
<p className="text-xs text-amber-400">{t('marketUi.escrowRequest.p2pkUnsupported')}</p>
)}
<div className="flex gap-2 pt-1">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
{t('common.cancel')}
</Button>
<Button
onClick={submit}
disabled={pending || !escrow.available || p2pkBlocked}
className="flex-1 gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Lock className="h-4 w-4" />}
{t('marketUi.escrowRequest.lockAndRequest')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}