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).
214 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|