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:
SkateSpots 2026-09-26 21:08:36 +07:00
parent 26e951710e
commit 15901976ef
8 changed files with 571 additions and 19 deletions

View file

@ -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`).

View 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>
);
}

View 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&apos;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>
);
}

View file

@ -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 &amp; 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}

View 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>
);
}

View 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,
};
}

View file

@ -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>
)}

View file

@ -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 />