feat(marketplace): wire Cashu escrow into the trade flow (native)
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.
This commit is contained in:
parent
26e951710e
commit
15901976ef
8 changed files with 571 additions and 19 deletions
|
|
@ -49,8 +49,10 @@ session with **no other memory of this project**. So:
|
|||
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
|
||||
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
||||
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
||||
`cashu_escrow` pubkey in kind 0. **Native-only**; wallet UI still needs
|
||||
on-device verification.
|
||||
`cashu_escrow` pubkey in kind 0. UI wired: `useCashuEscrow`,
|
||||
`EscrowKeyCard` (Wallet), `EscrowRequestDialog` (listing card), `EscrowPanel`
|
||||
+ redeem (DM request banner). **Native-only and UNVERIFIED on a device** —
|
||||
exercise with small amounts before trusting it with real money.
|
||||
- **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on
|
||||
web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`).
|
||||
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`).
|
||||
|
|
|
|||
74
src/components/EscrowPanel.tsx
Normal file
74
src/components/EscrowPanel.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { parseEscrowToken, type EscrowTokenInfo } from '@/lib/cashuEscrow';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(seconds: number): string {
|
||||
return new Date(seconds * 1000).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only escrow summary for a locked Cashu token. Pure/offline (uses
|
||||
* `parseEscrowToken`), so it renders on web as well as native — only the
|
||||
* mint-touching create/redeem actions are native-only.
|
||||
*/
|
||||
export function EscrowPanel({ token, className }: { token: string; className?: string }) {
|
||||
const info = useMemo<EscrowTokenInfo | null>(() => {
|
||||
try {
|
||||
return parseEscrowToken(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
if (!info || !info.locked) return null;
|
||||
const locktime = info.policy?.locktime;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-[#ff6600]/30 bg-[#ff6600]/5 p-3 text-xs',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-bold text-[#ff6600]">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
Escrowed {info.amount.toLocaleString()} sats
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 text-gray-400">
|
||||
<p>
|
||||
Mint: <span className="text-gray-300">{hostOf(info.mint)}</span>
|
||||
</p>
|
||||
{locktime ? (
|
||||
<p>
|
||||
{info.refundable ? (
|
||||
<>
|
||||
Buyer can refund since{' '}
|
||||
<span className="text-[#ccff00]">{formatDate(locktime)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Seller can redeem now · buyer refunds after{' '}
|
||||
<span className="text-gray-300">{formatDate(locktime)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>No expiry set on this token.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/EscrowRequestDialog.tsx
Normal file
154
src/components/EscrowRequestDialog.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,6 +33,8 @@ import { useSellerInfo } from '@/hooks/useSellerInfo';
|
|||
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||
import { EscrowRequestDialog } from '@/components/EscrowRequestDialog';
|
||||
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
||||
import { useListingTrade, useTradeAttestation, useTradeReputation, useListingTradeRequest, useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -54,6 +56,7 @@ import {
|
|||
Trash2,
|
||||
Loader2,
|
||||
Send,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
|
|
@ -100,6 +103,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
||||
const { publishRequest, isPending: requestingTrade } = useTradeAttestationRequest();
|
||||
const escrow = useCashuEscrow();
|
||||
const [showEscrow, setShowEscrow] = useState(false);
|
||||
|
||||
// Format price display
|
||||
const priceDisplay = item.price
|
||||
|
|
@ -205,6 +210,20 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
const iConfirmedTrade = !!trade?.iConfirmed;
|
||||
const iRequestedConfirmation = !!requestState?.iRequested;
|
||||
|
||||
// Seller's advertised Cashu escrow pubkey (kind 0 `cashu_escrow`), if any.
|
||||
const sellerEscrowPubkey =
|
||||
typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string'
|
||||
? ((metadata as Record<string, unknown>).cashu_escrow as string)
|
||||
: undefined;
|
||||
const canEscrow =
|
||||
escrow.available &&
|
||||
!!sellerEscrowPubkey &&
|
||||
!!user &&
|
||||
!isOwnListing &&
|
||||
!iConfirmedTrade &&
|
||||
!sellerNamedMe &&
|
||||
!iRequestedConfirmation;
|
||||
|
||||
const handleConfirmTrade = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
|
|
@ -525,8 +544,21 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{/* Buyer: lock funds in Cashu escrow and request confirmation (native) */}
|
||||
{canEscrow && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowEscrow(true)}
|
||||
className="w-full gap-2 border-[#ff6600]/40 text-[#ff6600] hover:bg-[#ff6600]/10"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
Escrow & request
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
|
||||
{user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
|
||||
{!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -573,6 +605,17 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Buyer: Cashu escrow + confirmation request (native-only) */}
|
||||
{canEscrow && sellerEscrowPubkey && (
|
||||
<EscrowRequestDialog
|
||||
open={showEscrow}
|
||||
onOpenChange={setShowEscrow}
|
||||
listing={listingRef}
|
||||
sellerPubkey={sellerEscrowPubkey}
|
||||
sellerName={displayName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
|
||||
<DeleteEntityDialog
|
||||
event={item.event}
|
||||
|
|
|
|||
76
src/components/wallet/EscrowKeyCard.tsx
Normal file
76
src/components/wallet/EscrowKeyCard.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { Handshake, Loader2, Check } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Lets a user advertise their Cashu escrow pubkey (kind 0 `cashu_escrow`) so
|
||||
* buyers can lock escrow funds to them. Native-only; renders nothing otherwise.
|
||||
*/
|
||||
export function EscrowKeyCard() {
|
||||
const escrow = useCashuEscrow();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey);
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!escrow.available || !user) return null;
|
||||
|
||||
const advertised =
|
||||
typeof (author.data?.metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string';
|
||||
|
||||
const publish = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await escrow.advertiseEscrowKey();
|
||||
toast({
|
||||
title: 'Escrow key published',
|
||||
description: 'Buyers can now lock escrow funds to you.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not publish key',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-black/90 border-2 border-[#ff6600]/25">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||
<Handshake className="h-5 w-5" />
|
||||
Escrow
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Advertise a public key so buyers can lock funds to you for a trade. Funds
|
||||
are refundable to the buyer if the deal falls through.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{advertised ? (
|
||||
<Badge className="gap-1 bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">
|
||||
<Check className="h-3 w-3" /> Escrow key active
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
onClick={publish}
|
||||
disabled={busy}
|
||||
className="gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Handshake className="h-4 w-4" />}
|
||||
Publish escrow key
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
151
src/hooks/wallet/useCashuEscrow.ts
Normal file
151
src/hooks/wallet/useCashuEscrow.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useCallback } from 'react';
|
||||
import { CashuMint, CashuWallet } from '@cashu/cashu-ts';
|
||||
import type { WebLNProvider } from '@webbtc/webln-types';
|
||||
import { useCashu } from './useCashu';
|
||||
import { useNWC } from '@/hooks/useNWCContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { isNativePlatform } from '@/lib/platform';
|
||||
import {
|
||||
createEscrowToken,
|
||||
normalizeCashuPubkey,
|
||||
parseEscrowToken,
|
||||
type EscrowMintWallet,
|
||||
type EscrowTokenInfo,
|
||||
} from '@/lib/cashuEscrow';
|
||||
|
||||
/**
|
||||
* Cashu escrow — **native-only** (the Cashu wallet is Capacitor-gated).
|
||||
*
|
||||
* Funds are locked to the seller with a locktime and the buyer as refund key
|
||||
* (see `ESCROW.md`). The correctness-critical flow lives in
|
||||
* `src/lib/cashuEscrow.ts` (unit-tested); this hook wires it to a real
|
||||
* cashu-ts wallet, the NWC/WebLN payment rail, and the coco keyring.
|
||||
*
|
||||
* EXPERIMENTAL: the mint-touching paths (create/redeem/refund) have not been
|
||||
* verified on a device yet — exercise with small amounts first.
|
||||
*/
|
||||
export function useCashuEscrow() {
|
||||
const { coco, isInitialized, mints } = useCashu();
|
||||
const { getActiveConnection, sendPayment } = useNWC();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey);
|
||||
const { mutateAsync: publish } = useNostrPublish();
|
||||
const webln = (globalThis as { webln?: WebLNProvider }).webln || null;
|
||||
|
||||
const available = isNativePlatform() && isInitialized && !!coco;
|
||||
|
||||
/** The active mint URL to fund escrow from (first trusted mint). */
|
||||
const defaultMintUrl = mints[0]?.url ?? null;
|
||||
|
||||
/**
|
||||
* The Cashu keyring pubkey used as the P2PK lock/refund key. Nostr signers
|
||||
* can't produce Cashu's raw schnorr witness, so escrow uses the Cashu
|
||||
* keyring — not the Nostr key.
|
||||
*/
|
||||
const getEscrowPubkey = useCallback(async (): Promise<string | null> => {
|
||||
if (!coco) return null;
|
||||
const existing = await coco.keyring.getLatestKeyPair();
|
||||
const keypair = existing ?? (await coco.keyring.generateKeyPair(true));
|
||||
return normalizeCashuPubkey(keypair.publicKeyHex);
|
||||
}, [coco]);
|
||||
|
||||
/** Pay a mint invoice via NWC, then WebLN. */
|
||||
const payInvoice = useCallback(
|
||||
async (bolt11: string): Promise<void> => {
|
||||
const nwc = getActiveConnection();
|
||||
if (nwc?.connectionString && nwc.isConnected) {
|
||||
await sendPayment(nwc, bolt11);
|
||||
return;
|
||||
}
|
||||
if (webln) {
|
||||
if (typeof webln.enable === 'function') await webln.enable();
|
||||
await webln.sendPayment(bolt11);
|
||||
return;
|
||||
}
|
||||
throw new Error('No Lightning wallet available to fund escrow — connect NWC or a WebLN wallet.');
|
||||
},
|
||||
[getActiveConnection, sendPayment, webln],
|
||||
);
|
||||
|
||||
const makeMintWallet = useCallback((mintUrl: string): EscrowMintWallet => {
|
||||
const wallet = new CashuWallet(new CashuMint(mintUrl), { unit: 'sat' });
|
||||
return {
|
||||
createMintQuote: (amount) => wallet.createMintQuote(amount),
|
||||
checkMintQuote: async (quote) => ({ state: (await wallet.checkMintQuote(quote)).state }),
|
||||
mintProofs: (amount, quote, options) =>
|
||||
wallet.mintProofs(amount, quote, { p2pk: options.p2pk }),
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create an escrowed token locked to `sellerPubkey` (funded by paying the
|
||||
* mint's Lightning invoice). Returns the token to embed in a `31872`.
|
||||
*/
|
||||
const createEscrow = useCallback(
|
||||
async ({
|
||||
amountSats,
|
||||
sellerPubkey,
|
||||
lockDays,
|
||||
mintUrl,
|
||||
}: {
|
||||
amountSats: number;
|
||||
sellerPubkey: string;
|
||||
lockDays?: number;
|
||||
mintUrl?: string;
|
||||
}): Promise<{ token: string; locktime: number; info: EscrowTokenInfo }> => {
|
||||
if (!coco) throw new Error('Cashu wallet is not ready.');
|
||||
|
||||
const buyerPubkey = await getEscrowPubkey();
|
||||
if (!buyerPubkey) throw new Error('Could not derive your escrow key.');
|
||||
|
||||
const mint = mintUrl ?? defaultMintUrl;
|
||||
if (!mint) throw new Error('No Cashu mint available — add one in your wallet first.');
|
||||
|
||||
const { token, locktime } = await createEscrowToken(
|
||||
{ wallet: makeMintWallet(mint), payInvoice },
|
||||
{ mintUrl: mint, amountSats, sellerPubkey, buyerPubkey, lockDays },
|
||||
);
|
||||
|
||||
return { token, locktime, info: parseEscrowToken(token) };
|
||||
},
|
||||
[coco, getEscrowPubkey, defaultMintUrl, makeMintWallet, payInvoice],
|
||||
);
|
||||
|
||||
/** Redeem an escrow token (seller on delivery, or buyer after the locktime). */
|
||||
const redeemEscrow = useCallback(
|
||||
async (token: string): Promise<void> => {
|
||||
if (!coco) throw new Error('Cashu wallet is not ready.');
|
||||
await coco.wallet.receive(token);
|
||||
},
|
||||
[coco],
|
||||
);
|
||||
|
||||
/**
|
||||
* Publish this user's escrow pubkey to their kind 0 profile (`cashu_escrow`)
|
||||
* so buyers can lock funds to them.
|
||||
*/
|
||||
const advertiseEscrowKey = useCallback(async (): Promise<string> => {
|
||||
if (!user) throw new Error('Log in first.');
|
||||
const pubkey = await getEscrowPubkey();
|
||||
if (!pubkey) throw new Error('Could not derive your escrow key.');
|
||||
|
||||
const current = (author.data?.metadata ?? {}) as Record<string, unknown>;
|
||||
await publish({
|
||||
kind: 0,
|
||||
content: JSON.stringify({ ...current, cashu_escrow: pubkey }),
|
||||
});
|
||||
return pubkey;
|
||||
}, [user, getEscrowPubkey, author.data?.metadata, publish]);
|
||||
|
||||
return {
|
||||
available,
|
||||
defaultMintUrl,
|
||||
getEscrowPubkey,
|
||||
createEscrow,
|
||||
redeemEscrow,
|
||||
payInvoice,
|
||||
advertiseEscrowKey,
|
||||
};
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ import { useNostrLogin } from '@nostrify/react/login';
|
|||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
|
||||
import { usePendingTradeConfirmations, useTradeAttestation, useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
|
||||
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
||||
import { EscrowPanel } from '@/components/EscrowPanel';
|
||||
import { useDMRead } from '@/hooks/useDMRead';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -108,6 +110,8 @@ export default function ConversationPage() {
|
|||
const { data: pendingTrades } = usePendingTradeConfirmations(user?.pubkey);
|
||||
const { data: incomingRequests } = useIncomingTradeRequests(user?.pubkey);
|
||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||
const escrow = useCashuEscrow();
|
||||
const [isRedeeming, setIsRedeeming] = useState(false);
|
||||
const { markRead } = useDMRead();
|
||||
const author = useAuthor(otherPubkey || undefined);
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -201,6 +205,29 @@ export default function ConversationPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// Escrow token attached to the incoming request, if any.
|
||||
const escrowToken = incomingRequest?.event.tags.find(([t]) => t === 'cashu_token')?.[1];
|
||||
|
||||
const handleRedeemEscrow = async () => {
|
||||
if (!escrowToken) return;
|
||||
setIsRedeeming(true);
|
||||
try {
|
||||
await escrow.redeemEscrow(escrowToken);
|
||||
toast({
|
||||
title: 'Escrow redeemed',
|
||||
description: 'The funds are now in your Cashu wallet.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not redeem escrow',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsRedeeming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -326,23 +353,46 @@ export default function ConversationPage() {
|
|||
{/* Incoming request: the other party asked us (seller) to record the sale */}
|
||||
{incomingRequest && (
|
||||
<Card className="mb-3 flex-shrink-0 border-2 border-[#ff6600]/40 bg-[#ff6600]/5">
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white">Confirm this sale?</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{displayName} asked you to record it. Confirming builds reputation for both of you.
|
||||
</p>
|
||||
<CardContent className="space-y-3 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white">Confirm this sale?</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{displayName} asked you to record it. Confirming builds reputation for both of you.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleFulfillRequest}
|
||||
disabled={confirmingTrade}
|
||||
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{confirmingTrade ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Handshake className="h-3.5 w-3.5" />}
|
||||
Confirm sale
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleFulfillRequest}
|
||||
disabled={confirmingTrade}
|
||||
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{confirmingTrade ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Handshake className="h-3.5 w-3.5" />}
|
||||
Confirm sale
|
||||
</Button>
|
||||
|
||||
{escrowToken && (
|
||||
<div className="space-y-2">
|
||||
<EscrowPanel token={escrowToken} />
|
||||
{escrow.available ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRedeemEscrow}
|
||||
disabled={isRedeeming}
|
||||
className="w-full gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{isRedeeming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Handshake className="h-3.5 w-3.5" />}
|
||||
Redeem escrow
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Cashu escrow is app-only — open SkateSpots on your phone to redeem.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useCashu } from '@/hooks/wallet/useCashu';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { CashuWalletCard } from '@/components/wallet/CashuWalletCard';
|
||||
import { EscrowKeyCard } from '@/components/wallet/EscrowKeyCard';
|
||||
import { NwcWalletCard } from '@/components/wallet/NwcWalletCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
|
|
@ -254,6 +255,7 @@ export default function WalletPage() {
|
|||
|
||||
{/* Cashu wallet — native app, self-custodial ecash */}
|
||||
<CashuWalletCard />
|
||||
<EscrowKeyCard />
|
||||
|
||||
{/* Get a wallet — collapsed by default */}
|
||||
<GetAWallet />
|
||||
|
|
|
|||
Loading…
Reference in a new issue