Add Cashu escrow settings + a "require escrow" listing flag

- useEscrowPrefs (device-local): default lock window, preferred escrow mint,
  require-escrow default for new listings.
- EscrowSettingsCard on the Wallet (native-only, beside EscrowKeyCard).
- EscrowRequestDialog now defaults its lock window + funding mint from the prefs.
- Listings gain an optional ['escrow','required'] tag: a toggle on
  CreateListingPage (defaulted from the pref) and a 🔒 badge on the card.
This commit is contained in:
SkateSpots 2026-10-04 17:54:42 +07:00
parent d82ecf4adf
commit fb42f4f0d1
9 changed files with 171 additions and 3 deletions

View file

@ -10,9 +10,10 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow'; import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations'; import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { ESCROW_DEFAULT_LOCK_DAYS, ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow'; import { ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
import { Loader2, Lock } from 'lucide-react'; import { Loader2, Lock } from 'lucide-react';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -39,11 +40,12 @@ export function EscrowRequestDialog({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const escrow = useCashuEscrow(); const escrow = useCashuEscrow();
const { prefs } = useEscrowPrefs();
const { publishRequest, isPending: publishing } = useTradeAttestationRequest(); const { publishRequest, isPending: publishing } = useTradeAttestationRequest();
const { toast } = useToast(); const { toast } = useToast();
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [days, setDays] = useState(String(ESCROW_DEFAULT_LOCK_DAYS)); const [days, setDays] = useState(String(prefs.defaultLockDays));
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const submit = async () => { const submit = async () => {
@ -61,7 +63,7 @@ export function EscrowRequestDialog({
setBusy(true); setBusy(true);
try { try {
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays }); const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: prefs.preferredMintUrl || undefined });
await publishRequest({ await publishRequest({
listing, listing,
attestor: sellerPubkey, attestor: sellerPubkey,

View file

@ -363,6 +363,11 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
: `📍📦 ${t('marketUi.gearSwap.pickupOrShip')}`} : `📍📦 ${t('marketUi.gearSwap.pickupOrShip')}`}
</Badge> </Badge>
)} )}
{item.escrow === 'required' && (
<Badge variant="outline" className="text-xs border-[#ff6600]/50 bg-[#ff6600]/10 text-[#ff6600]">
🔒 {t('marketUi.create.requireEscrow')}
</Badge>
)}
{distanceKm != null && ( {distanceKm != null && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50"> <Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{distanceKm < 1 {distanceKm < 1

View file

@ -0,0 +1,87 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Settings2 } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
import { useCashu } from '@/hooks/wallet/useCashu';
import { isNativePlatform } from '@/lib/platform';
import { ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
/**
* Cashu escrow preferences (device-local): the default lock window offered to
* buyers, the mint funds come from, and whether new listings default to
* requiring escrow. Native-only, like the rest of the Cashu wallet.
*/
export function EscrowSettingsCard() {
const { t } = useTranslation();
const { prefs, setPrefs } = useEscrowPrefs();
const { mints, isInitialized } = useCashu();
if (!isNativePlatform() || !isInitialized) return null;
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]">
<Settings2 className="h-5 w-5" />
{t('walletUi.escrowSettings.title')}
</CardTitle>
<CardDescription>{t('walletUi.escrowSettings.desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Default lock window */}
<div className="space-y-2">
<Label htmlFor="escrow-lock-days">{t('walletUi.escrowSettings.lockWindow')}</Label>
<div className="flex items-center gap-2">
<Input
id="escrow-lock-days"
type="number"
inputMode="numeric"
min={ESCROW_MIN_LOCK_DAYS}
value={String(prefs.defaultLockDays)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setPrefs({ ...prefs, defaultLockDays: Number.isFinite(n) ? Math.max(ESCROW_MIN_LOCK_DAYS, n) : ESCROW_MIN_LOCK_DAYS });
}}
className="max-w-24 bg-white/5 border-white/10"
/>
<span className="text-sm text-muted-foreground">{t('walletUi.escrowSettings.days')}</span>
</div>
<p className="text-xs text-muted-foreground">{t('walletUi.escrowSettings.lockWindowHint')}</p>
</div>
{/* Preferred mint */}
<div className="space-y-2">
<Label>{t('walletUi.escrowSettings.preferredMint')}</Label>
<Select
value={prefs.preferredMintUrl || '__active__'}
onValueChange={(v) => setPrefs({ ...prefs, preferredMintUrl: v === '__active__' ? '' : v })}
>
<SelectTrigger className="bg-white/5 border-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__active__">{t('walletUi.escrowSettings.activeMint')}</SelectItem>
{mints.map((m) => (
<SelectItem key={m.url} value={m.url}>{m.url.replace(/^https?:\/\//, '')}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('walletUi.escrowSettings.preferredMintHint')}</p>
</div>
{/* Require escrow on new listings */}
<div className="flex items-center justify-between gap-3 rounded-lg border border-white/10 p-3">
<div className="min-w-0">
<Label className="text-sm">{t('walletUi.escrowSettings.requireListings')}</Label>
<p className="text-xs text-muted-foreground">{t('walletUi.escrowSettings.requireListingsHint')}</p>
</div>
<Switch checked={prefs.requireEscrow} onCheckedChange={(v) => setPrefs({ ...prefs, requireEscrow: v })} />
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,25 @@
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { ESCROW_DEFAULT_LOCK_DAYS } from '@/lib/cashuEscrow';
export interface EscrowPrefs {
/** Default lock window (days) offered when a buyer opens the escrow dialog. */
defaultLockDays: number;
/** Preferred mint URL for funding escrow ('' = the active/first mint). */
preferredMintUrl: string;
/** Default the "require escrow" flag on new listings. */
requireEscrow: boolean;
}
const DEFAULT_PREFS: EscrowPrefs = {
defaultLockDays: ESCROW_DEFAULT_LOCK_DAYS,
preferredMintUrl: '',
requireEscrow: false,
};
const KEY = 'skatespots:escrow:prefs';
/** Device-local Cashu escrow preferences (see ESCROW.md). */
export function useEscrowPrefs() {
const [prefs, setPrefs] = useLocalStorage<EscrowPrefs>(KEY, DEFAULT_PREFS);
return { prefs, setPrefs };
}

View file

@ -1510,6 +1510,8 @@ marketUi: {
bothHint: 'Either', bothHint: 'Either',
addressNote: 'We never collect your address — for shipping, share it privately in the trade chat.', addressNote: 'We never collect your address — for shipping, share it privately in the trade chat.',
postageLabel: 'Who pays postage?', postageLabel: 'Who pays postage?',
requireEscrow: 'Require escrow',
requireEscrowHint: 'Ask the buyer to settle through Cashu escrow.',
postageBuyer: 'Buyer pays', postageBuyer: 'Buyer pays',
postageSeller: 'I pay', postageSeller: 'I pay',
postageSplit: 'Split', postageSplit: 'Split',
@ -5759,6 +5761,18 @@ walletUi: {
publishedDesc: 'Buyers can now lock funds to you for a trade.', publishedDesc: 'Buyers can now lock funds to you for a trade.',
failed: 'Could not turn on escrow', failed: 'Could not turn on escrow',
}, },
escrowSettings: {
title: 'Escrow settings',
desc: 'Defaults for Cashu escrow trades. Device-local.',
lockWindow: 'Default lock window',
lockWindowHint: 'How long funds stay locked before the buyer can reclaim them. Longer is safer for the seller.',
days: 'days',
preferredMint: 'Mint for escrow',
activeMint: 'Active mint',
preferredMintHint: 'Which mint funds escrow. Use a trustworthy one.',
requireListings: 'Require escrow on new listings',
requireListingsHint: 'Pre-selects “Require escrow” when you list an item.',
},
privacy: { privacy: {
title: 'Privacy Tips', title: 'Privacy Tips',
subtitle: 'Learn how to protect your financial privacy', subtitle: 'Learn how to protect your financial privacy',

View file

@ -1510,6 +1510,8 @@ marketUi: {
bothHint: 'Cualquiera', bothHint: 'Cualquiera',
addressNote: 'Nunca recogemos tu dirección — para envíos, compártela en privado en el chat del trato.', addressNote: 'Nunca recogemos tu dirección — para envíos, compártela en privado en el chat del trato.',
postageLabel: '¿Quién paga el envío?', postageLabel: '¿Quién paga el envío?',
requireEscrow: 'Exigir depósito',
requireEscrowHint: 'Pide al comprador liquidar mediante depósito Cashu.',
postageBuyer: 'Paga el comprador', postageBuyer: 'Paga el comprador',
postageSeller: 'Pago yo', postageSeller: 'Pago yo',
postageSplit: 'A medias', postageSplit: 'A medias',
@ -5759,6 +5761,18 @@ walletUi: {
publishedDesc: 'Los compradores ya pueden bloquear fondos para ti en un trato.', publishedDesc: 'Los compradores ya pueden bloquear fondos para ti en un trato.',
failed: 'No se pudo activar el depósito', failed: 'No se pudo activar el depósito',
}, },
escrowSettings: {
title: 'Ajustes del depósito',
desc: 'Valores por defecto para tratos con depósito Cashu. Local del dispositivo.',
lockWindow: 'Plazo de bloqueo por defecto',
lockWindowHint: 'Cuánto tiempo quedan bloqueados los fondos antes de que el comprador pueda recuperarlos. Más largo es más seguro para el vendedor.',
days: 'días',
preferredMint: 'Mint para el depósito',
activeMint: 'Mint activo',
preferredMintHint: 'Qué mint financia el depósito. Usa uno de confianza.',
requireListings: 'Exigir depósito en anuncios nuevos',
requireListingsHint: 'Preselecciona «Exigir depósito» al publicar un artículo.',
},
privacy: { privacy: {
title: 'Consejos de privacidad', title: 'Consejos de privacidad',
subtitle: 'Aprende a proteger tu privacidad financiera', subtitle: 'Aprende a proteger tu privacidad financiera',

View file

@ -3702,6 +3702,8 @@ export interface MarketplaceItem {
fulfilment?: 'pickup' | 'ships' | 'both'; fulfilment?: 'pickup' | 'ships' | 'both';
/** Who pays postage, when the deal ships. */ /** Who pays postage, when the deal ships. */
shipping?: 'buyer' | 'seller' | 'split'; shipping?: 'buyer' | 'seller' | 'split';
/** Seller asks the buyer to settle via Cashu escrow. */
escrow?: 'required';
/** NIP-13 proof-of-work difficulty (leading zero bits) on the event id. */ /** NIP-13 proof-of-work difficulty (leading zero bits) on the event id. */
powDifficulty?: number; powDifficulty?: number;
expiration?: number; // Unix timestamp expiration?: number; // Unix timestamp
@ -3785,6 +3787,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
const listingType = event.tags.find(([name]) => name === 'listing_type')?.[1] as MarketplaceItem['listingType']; const listingType = event.tags.find(([name]) => name === 'listing_type')?.[1] as MarketplaceItem['listingType'];
const fulfilment = event.tags.find(([name]) => name === 'fulfilment')?.[1] as MarketplaceItem['fulfilment']; const fulfilment = event.tags.find(([name]) => name === 'fulfilment')?.[1] as MarketplaceItem['fulfilment'];
const shipping = event.tags.find(([name]) => name === 'shipping')?.[1] as MarketplaceItem['shipping']; const shipping = event.tags.find(([name]) => name === 'shipping')?.[1] as MarketplaceItem['shipping'];
const escrow = event.tags.find(([name]) => name === 'escrow')?.[1] as MarketplaceItem['escrow'];
const powDifficulty = event.tags.some(([name]) => name === 'nonce') const powDifficulty = event.tags.some(([name]) => name === 'nonce')
? countLeadingZeroBits(event.id) ? countLeadingZeroBits(event.id)
: undefined; : undefined;
@ -3823,6 +3826,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
listingType, listingType,
fulfilment, fulfilment,
shipping, shipping,
escrow,
powDifficulty, powDifficulty,
size, size,
expiration, expiration,

View file

@ -14,6 +14,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { Switch } from '@/components/ui/switch';
import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
import { useSkateSpot, useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateSpot, useSkateSpots } from '@/hooks/useSkateSpots';
import { useListingRestrictions, recordListingCreation } from '@/hooks/useListingRestrictions'; import { useListingRestrictions, recordListingCreation } from '@/hooks/useListingRestrictions';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
@ -65,6 +67,8 @@ export default function CreateListingPage() {
const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling'); const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling');
const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>('pickup'); const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>('pickup');
const [shippingWho, setShippingWho] = useState<'buyer' | 'seller' | 'split'>('buyer'); const [shippingWho, setShippingWho] = useState<'buyer' | 'seller' | 'split'>('buyer');
const { prefs: escrowPrefs } = useEscrowPrefs();
const [requireEscrow, setRequireEscrow] = useState(escrowPrefs.requireEscrow);
const [category, setCategory] = useState(searchParams.get('category') || ''); const [category, setCategory] = useState(searchParams.get('category') || '');
const [title, setTitle] = useState(searchParams.get('title') || ''); const [title, setTitle] = useState(searchParams.get('title') || '');
const [description, setDescription] = useState(searchParams.get('description') || ''); const [description, setDescription] = useState(searchParams.get('description') || '');
@ -282,6 +286,7 @@ export default function CreateListingPage() {
if (listingType === 'selling') { if (listingType === 'selling') {
tags.push(['fulfilment', fulfilment]); tags.push(['fulfilment', fulfilment]);
if (fulfilment !== 'pickup') tags.push(['shipping', shippingWho]); if (fulfilment !== 'pickup') tags.push(['shipping', shippingWho]);
if (requireEscrow) tags.push(['escrow', 'required']);
} }
// Size with optional modifiers // Size with optional modifiers
@ -755,6 +760,16 @@ export default function CreateListingPage() {
</div> </div>
)} )}
{listingType === 'selling' && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-gray-700 p-3">
<div className="min-w-0">
<Label className="text-sm">{t('marketUi.create.requireEscrow')}</Label>
<p className="text-xs text-gray-500">{t('marketUi.create.requireEscrowHint')}</p>
</div>
<Switch checked={requireEscrow} onCheckedChange={setRequireEscrow} />
</div>
)}
{/* Size */} {/* Size */}
{currentSizeOptions.length > 0 && ( {currentSizeOptions.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">

View file

@ -12,6 +12,7 @@ import { useTranslation } from '@/contexts/language';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { CashuWalletCard } from '@/components/wallet/CashuWalletCard'; import { CashuWalletCard } from '@/components/wallet/CashuWalletCard';
import { EscrowKeyCard } from '@/components/wallet/EscrowKeyCard'; import { EscrowKeyCard } from '@/components/wallet/EscrowKeyCard';
import { EscrowSettingsCard } from '@/components/wallet/EscrowSettingsCard';
import { NwcWalletCard } from '@/components/wallet/NwcWalletCard'; import { NwcWalletCard } from '@/components/wallet/NwcWalletCard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@ -263,6 +264,7 @@ export default function WalletPage() {
{/* Cashu wallet — native app, self-custodial ecash */} {/* Cashu wallet — native app, self-custodial ecash */}
<CashuWalletCard /> <CashuWalletCard />
<EscrowKeyCard /> <EscrowKeyCard />
<EscrowSettingsCard />
<Link <Link
to="/mints" to="/mints"