From 15901976efa021cf1b273bd5250ea25d9ad0f502 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 21:08:36 +0700 Subject: [PATCH] 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. --- AGENTS.md | 6 +- src/components/EscrowPanel.tsx | 74 ++++++++++++ src/components/EscrowRequestDialog.tsx | 154 ++++++++++++++++++++++++ src/components/MarketplaceItemCard.tsx | 45 ++++++- src/components/wallet/EscrowKeyCard.tsx | 76 ++++++++++++ src/hooks/wallet/useCashuEscrow.ts | 151 +++++++++++++++++++++++ src/pages/ConversationPage.tsx | 82 ++++++++++--- src/pages/WalletPage.tsx | 2 + 8 files changed, 571 insertions(+), 19 deletions(-) create mode 100644 src/components/EscrowPanel.tsx create mode 100644 src/components/EscrowRequestDialog.tsx create mode 100644 src/components/wallet/EscrowKeyCard.tsx create mode 100644 src/hooks/wallet/useCashuEscrow.ts diff --git a/AGENTS.md b/AGENTS.md index 4eedd7c..e0703a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/src/components/EscrowPanel.tsx b/src/components/EscrowPanel.tsx new file mode 100644 index 0000000..2e6ea32 --- /dev/null +++ b/src/components/EscrowPanel.tsx @@ -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(() => { + try { + return parseEscrowToken(token); + } catch { + return null; + } + }, [token]); + + if (!info || !info.locked) return null; + const locktime = info.policy?.locktime; + + return ( +
+
+ + Escrowed {info.amount.toLocaleString()} sats +
+
+

+ Mint: {hostOf(info.mint)} +

+ {locktime ? ( +

+ {info.refundable ? ( + <> + Buyer can refund since{' '} + {formatDate(locktime)} + + ) : ( + <> + Seller can redeem now · buyer refunds after{' '} + {formatDate(locktime)} + + )} +

+ ) : ( +

No expiry set on this token.

+ )} +
+
+ ); +} diff --git a/src/components/EscrowRequestDialog.tsx b/src/components/EscrowRequestDialog.tsx new file mode 100644 index 0000000..4f42199 --- /dev/null +++ b/src/components/EscrowRequestDialog.tsx @@ -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 ( + + + + + Escrow this trade + + + Funds are locked to {sellerName} and refundable by you if the deal falls + through after the deadline. Requires a connected Lightning wallet. + + + +
+
+ + setAmount(e.target.value)} + inputMode="numeric" + placeholder="e.g. 45000" + className="h-11 border-gray-700 bg-gray-900 text-white" + /> +
+ +
+ + setDays(e.target.value)} + inputMode="numeric" + className="h-11 border-gray-700 bg-gray-900 text-white" + /> +

+ After this many days you can reclaim the funds if the seller hasn't delivered. +

+
+ + {!escrow.available && ( +

+ Cashu escrow is available in the SkateSpots app only (not the web build). +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx index b8dfe9f..ca93862 100644 --- a/src/components/MarketplaceItemCard.tsx +++ b/src/components/MarketplaceItemCard.tsx @@ -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 | undefined)?.cashu_escrow === 'string' + ? ((metadata as Record).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 )} + {/* Buyer: lock funds in Cashu escrow and request confirmation (native) */} + {canEscrow && ( + + )} + {/* Buyer: ask the seller to record the sale (kind 31872 request) */} - {user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && ( + {!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && ( + )} + + + ); +} diff --git a/src/hooks/wallet/useCashuEscrow.ts b/src/hooks/wallet/useCashuEscrow.ts new file mode 100644 index 0000000..57b8720 --- /dev/null +++ b/src/hooks/wallet/useCashuEscrow.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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; + 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, + }; +} diff --git a/src/pages/ConversationPage.tsx b/src/pages/ConversationPage.tsx index 8f7aa07..a7243a0 100644 --- a/src/pages/ConversationPage.tsx +++ b/src/pages/ConversationPage.tsx @@ -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 && ( - - -
-

Confirm this sale?

-

- {displayName} asked you to record it. Confirming builds reputation for both of you. -

+ +
+ +
+

Confirm this sale?

+

+ {displayName} asked you to record it. Confirming builds reputation for both of you. +

+
+
- + + {escrowToken && ( +
+ + {escrow.available ? ( + + ) : ( +

+ Cashu escrow is app-only — open SkateSpots on your phone to redeem. +

+ )} +
+ )}
)} diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 854f41c..046f4db 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -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 */} + {/* Get a wallet — collapsed by default */}