Completes the escrow feature on top of the tested core (src/lib/cashuEscrow.ts): - useCashuEscrow: native-only hook wiring createEscrow (cashu-ts mint + NWC/WebLN funding), redeemEscrow, escrow-key derivation (coco keyring) and profile publish - EscrowKeyCard (Wallet): advertise the kind-0 'cashu_escrow' pubkey - EscrowRequestDialog (listing card): buyer locks funds (P2PK to seller, refundable after N days) and sends the 31872 carrying the cashu_token - EscrowPanel + 'Redeem escrow' in the DM request banner (seller side); the panel is offline/parse-based so it renders on web too - Buyer 'Escrow & request' supersedes the plain request when available EXPERIMENTAL: mint-touching paths are unverified on a device (native-only wallet); use small amounts first. Gate: 101 tests, 0 errors.
154 lines
5.3 KiB
TypeScript
154 lines
5.3 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 { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { ESCROW_DEFAULT_LOCK_DAYS, ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
|
|
import { Loader2, Lock } from 'lucide-react';
|
|
|
|
/**
|
|
* 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 escrow = useCashuEscrow();
|
|
const { publishRequest, isPending: publishing } = useTradeAttestationRequest();
|
|
const { toast } = useToast();
|
|
|
|
const [amount, setAmount] = useState('');
|
|
const [days, setDays] = useState(String(ESCROW_DEFAULT_LOCK_DAYS));
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const submit = async () => {
|
|
const amountSats = parseInt(amount, 10);
|
|
const lockDays = parseInt(days, 10);
|
|
|
|
if (!Number.isFinite(amountSats) || amountSats <= 0) {
|
|
toast({ title: 'Enter an escrow amount', variant: 'destructive' });
|
|
return;
|
|
}
|
|
if (!Number.isFinite(lockDays) || lockDays < ESCROW_MIN_LOCK_DAYS) {
|
|
toast({ title: `Lock for at least ${ESCROW_MIN_LOCK_DAYS} day`, variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
setBusy(true);
|
|
try {
|
|
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays });
|
|
await publishRequest({
|
|
listing,
|
|
attestor: sellerPubkey,
|
|
cashuToken: token,
|
|
note: `Escrowed ${amountSats} sats for our trade — please confirm the sale.`,
|
|
});
|
|
toast({
|
|
title: 'Escrow created 🎉',
|
|
description: `${amountSats} sats locked for ${lockDays} days. ${sellerName} can redeem on delivery.`,
|
|
});
|
|
onOpenChange(false);
|
|
} catch (e) {
|
|
toast({
|
|
title: 'Could not create escrow',
|
|
description: e instanceof Error ? e.message : 'Something went wrong.',
|
|
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]" /> Escrow this trade
|
|
</DialogTitle>
|
|
<DialogDescription className="text-sm text-gray-400">
|
|
Funds are locked to {sellerName} and refundable by you if the deal falls
|
|
through after the deadline. Requires a connected Lightning wallet.
|
|
</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">
|
|
Amount (sats)
|
|
</Label>
|
|
<Input
|
|
id="escrow-amount"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
inputMode="numeric"
|
|
placeholder="e.g. 45000"
|
|
className="h-11 border-gray-700 bg-gray-900 text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="escrow-days" className="text-xs uppercase tracking-wide text-gray-400">
|
|
Buyer refund after (days)
|
|
</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">
|
|
After this many days you can reclaim the funds if the seller hasn't delivered.
|
|
</p>
|
|
</div>
|
|
|
|
{!escrow.available && (
|
|
<p className="text-xs text-amber-400">
|
|
Cashu escrow is available in the SkateSpots app only (not the web build).
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-1">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={submit}
|
|
disabled={pending || !escrow.available}
|
|
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" />}
|
|
Lock & request
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|