From fb42f4f0d1900148f7dd4ee506d13701be4090e3 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 4 Oct 2026 17:54:42 +0700 Subject: [PATCH] Add Cashu escrow settings + a "require escrow" listing flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/components/EscrowRequestDialog.tsx | 8 +- src/components/MarketplaceItemCard.tsx | 5 ++ src/components/wallet/EscrowSettingsCard.tsx | 87 ++++++++++++++++++++ src/hooks/wallet/useEscrowPrefs.ts | 25 ++++++ src/i18n/en.ts | 14 ++++ src/i18n/es.ts | 14 ++++ src/lib/types.ts | 4 + src/pages/CreateListingPage.tsx | 15 ++++ src/pages/WalletPage.tsx | 2 + 9 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 src/components/wallet/EscrowSettingsCard.tsx create mode 100644 src/hooks/wallet/useEscrowPrefs.ts diff --git a/src/components/EscrowRequestDialog.tsx b/src/components/EscrowRequestDialog.tsx index e4eb2f4..25b25d1 100644 --- a/src/components/EscrowRequestDialog.tsx +++ b/src/components/EscrowRequestDialog.tsx @@ -10,9 +10,10 @@ 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 { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs'; import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations'; 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 { useTranslation } from '@/contexts/language'; @@ -39,11 +40,12 @@ export function EscrowRequestDialog({ }) { const { t } = useTranslation(); const escrow = useCashuEscrow(); + const { prefs } = useEscrowPrefs(); const { publishRequest, isPending: publishing } = useTradeAttestationRequest(); const { toast } = useToast(); 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 submit = async () => { @@ -61,7 +63,7 @@ export function EscrowRequestDialog({ setBusy(true); try { - const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays }); + const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: prefs.preferredMintUrl || undefined }); await publishRequest({ listing, attestor: sellerPubkey, diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx index 0b90a51..8a732b7 100644 --- a/src/components/MarketplaceItemCard.tsx +++ b/src/components/MarketplaceItemCard.tsx @@ -363,6 +363,11 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa : `📍📦 ${t('marketUi.gearSwap.pickupOrShip')}`} )} + {item.escrow === 'required' && ( + + 🔒 {t('marketUi.create.requireEscrow')} + + )} {distanceKm != null && ( {distanceKm < 1 diff --git a/src/components/wallet/EscrowSettingsCard.tsx b/src/components/wallet/EscrowSettingsCard.tsx new file mode 100644 index 0000000..6539781 --- /dev/null +++ b/src/components/wallet/EscrowSettingsCard.tsx @@ -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 ( + + + + + {t('walletUi.escrowSettings.title')} + + {t('walletUi.escrowSettings.desc')} + + + {/* Default lock window */} +
+ +
+ { + 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" + /> + {t('walletUi.escrowSettings.days')} +
+

{t('walletUi.escrowSettings.lockWindowHint')}

+
+ + {/* Preferred mint */} +
+ + +

{t('walletUi.escrowSettings.preferredMintHint')}

+
+ + {/* Require escrow on new listings */} +
+
+ +

{t('walletUi.escrowSettings.requireListingsHint')}

+
+ setPrefs({ ...prefs, requireEscrow: v })} /> +
+
+
+ ); +} diff --git a/src/hooks/wallet/useEscrowPrefs.ts b/src/hooks/wallet/useEscrowPrefs.ts new file mode 100644 index 0000000..20517dd --- /dev/null +++ b/src/hooks/wallet/useEscrowPrefs.ts @@ -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(KEY, DEFAULT_PREFS); + return { prefs, setPrefs }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2ac87c7..38d9ccc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1510,6 +1510,8 @@ marketUi: { bothHint: 'Either', addressNote: 'We never collect your address — for shipping, share it privately in the trade chat.', postageLabel: 'Who pays postage?', + requireEscrow: 'Require escrow', + requireEscrowHint: 'Ask the buyer to settle through Cashu escrow.', postageBuyer: 'Buyer pays', postageSeller: 'I pay', postageSplit: 'Split', @@ -5759,6 +5761,18 @@ walletUi: { publishedDesc: 'Buyers can now lock funds to you for a trade.', 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: { title: 'Privacy Tips', subtitle: 'Learn how to protect your financial privacy', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 6ab1535..3acfbc5 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -1510,6 +1510,8 @@ marketUi: { bothHint: 'Cualquiera', 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?', + requireEscrow: 'Exigir depósito', + requireEscrowHint: 'Pide al comprador liquidar mediante depósito Cashu.', postageBuyer: 'Paga el comprador', postageSeller: 'Pago yo', postageSplit: 'A medias', @@ -5759,6 +5761,18 @@ walletUi: { publishedDesc: 'Los compradores ya pueden bloquear fondos para ti en un trato.', 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: { title: 'Consejos de privacidad', subtitle: 'Aprende a proteger tu privacidad financiera', diff --git a/src/lib/types.ts b/src/lib/types.ts index 8090e45..957cbe5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3702,6 +3702,8 @@ export interface MarketplaceItem { fulfilment?: 'pickup' | 'ships' | 'both'; /** Who pays postage, when the deal ships. */ 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. */ powDifficulty?: number; 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 fulfilment = event.tags.find(([name]) => name === 'fulfilment')?.[1] as MarketplaceItem['fulfilment']; 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') ? countLeadingZeroBits(event.id) : undefined; @@ -3823,6 +3826,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null listingType, fulfilment, shipping, + escrow, powDifficulty, size, expiration, diff --git a/src/pages/CreateListingPage.tsx b/src/pages/CreateListingPage.tsx index c5dbe9a..3e6ba73 100644 --- a/src/pages/CreateListingPage.tsx +++ b/src/pages/CreateListingPage.tsx @@ -14,6 +14,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { Switch } from '@/components/ui/switch'; +import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs'; import { useSkateSpot, useSkateSpots } from '@/hooks/useSkateSpots'; import { useListingRestrictions, recordListingCreation } from '@/hooks/useListingRestrictions'; import { LoginArea } from '@/components/auth/LoginArea'; @@ -65,6 +67,8 @@ export default function CreateListingPage() { const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling'); const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>('pickup'); 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 [title, setTitle] = useState(searchParams.get('title') || ''); const [description, setDescription] = useState(searchParams.get('description') || ''); @@ -282,6 +286,7 @@ export default function CreateListingPage() { if (listingType === 'selling') { tags.push(['fulfilment', fulfilment]); if (fulfilment !== 'pickup') tags.push(['shipping', shippingWho]); + if (requireEscrow) tags.push(['escrow', 'required']); } // Size with optional modifiers @@ -755,6 +760,16 @@ export default function CreateListingPage() { )} + {listingType === 'selling' && ( +
+
+ +

{t('marketUi.create.requireEscrowHint')}

+
+ +
+ )} + {/* Size */} {currentSizeOptions.length > 0 && (
diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 8d67218..feab602 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -12,6 +12,7 @@ import { useTranslation } from '@/contexts/language'; import { LoginArea } from '@/components/auth/LoginArea'; import { CashuWalletCard } from '@/components/wallet/CashuWalletCard'; import { EscrowKeyCard } from '@/components/wallet/EscrowKeyCard'; +import { EscrowSettingsCard } from '@/components/wallet/EscrowSettingsCard'; import { NwcWalletCard } from '@/components/wallet/NwcWalletCard'; import { cn } from '@/lib/utils'; import { @@ -263,6 +264,7 @@ export default function WalletPage() { {/* Cashu wallet — native app, self-custodial ecash */} +