From 077fb2d5d1a4dd2afd93a2e80286ed9eb4c4102d Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 9 Oct 2026 23:00:35 +0700 Subject: [PATCH] fix(marketplace): payment, attestation, and escrow safety Next batch from MARKETPLACE_FLOW_AUDIT.md: - bolt11.ts: getBolt11Expiry / isBolt11Expired; usePayPaymentPointer refuses an expired invoice before paying (a lapsed offer invoice used to fail deep in the wallet with a confusing error). - PayWithSats shows unsupported pointer kinds (creq/clink) as unavailable up front instead of enabling a button that fails after the click. - useTradeAttestations: verify events (onlyVerified) and keep the newest per (author, kind, d), so a newer s=revoked supersedes a stale valid copy; useTradeReputation no longer trusts unverified relay events. - EscrowRequestDialog persists the minted token locally before publishing (lib/pendingEscrow.ts), so a failed send cannot strand the locked funds. --- MARKETPLACE_FLOW_AUDIT.md | 40 ++++++++++----- src/components/EscrowRequestDialog.tsx | 11 +++- src/components/PayWithSats.tsx | 8 +++ src/hooks/usePayPaymentPointer.ts | 12 +++-- src/hooks/useTradeAttestations.ts | 35 +++++++++---- src/i18n/en.ts | 2 + src/i18n/es.ts | 2 + src/lib/bolt11.ts | 48 +++++++++++++++++ src/lib/pendingEscrow.ts | 71 ++++++++++++++++++++++++++ 9 files changed, 201 insertions(+), 28 deletions(-) create mode 100644 src/lib/pendingEscrow.ts diff --git a/MARKETPLACE_FLOW_AUDIT.md b/MARKETPLACE_FLOW_AUDIT.md index 6cbd2c5..3da151d 100644 --- a/MARKETPLACE_FLOW_AUDIT.md +++ b/MARKETPLACE_FLOW_AUDIT.md @@ -168,6 +168,7 @@ raw offer `status` (`ListingOffersPanel`), raw currency codes ## 6. Fixed in this pass +First batch (correctness): 1. Grid "Mark as sold" now opens `MarkSoldDialog` (records the `31871`). 2. Offers accept: only the listing owner attaches a payment pointer (payee fix). 3. `advertiseEscrowKey` refuses to publish kind 0 before the profile has loaded. @@ -176,21 +177,32 @@ raw offer `status` (`ListingOffersPanel`), raw currency codes (Plus, earlier today: `useMarketplaceItem` moderation filter, offer CTA guards, URL-addressable hub tabs, offers panel error branch.) +Second batch (payment, attestation, escrow): +6. **BOLT11 expiry** is checked before paying (`bolt11.ts` `getBolt11Expiry` / + `isBolt11Expired`), with a clear "invoice expired" error. +7. **Unsupported pointer kinds** (`creq` / `clink`) are shown as unavailable up + front in `PayWithSats` instead of enabling a button that fails after the click. +8. **Signature + revocation:** `useTradeReputation` and `useListingTrade` verify + events (`onlyVerified`) and keep the newest per (author, kind, d), so a newer + `s=revoked` supersedes a stale `valid` copy; the pending/request queries are + verified too. +9. **Escrow fund safety:** creation persists the minted token locally + (`lib/pendingEscrow.ts`) *before* publishing the request, so a failed send + cannot strand the locked funds, and the failure message says so. (Still a + mitigation: there is no re-send/recover UI yet.) + ## 7. Recommended order -1. **P0 escrow safety:** make `EscrowRequestDialog` persist/attach the minted token - before publishing (or roll back and surface it) so a failed publish cannot - strand funds; persist the token so a reload cannot lose it. Native-only, but it - is real money. -2. **P0 payee/pointer hardening:** verify `PayWithSats` only offers payment when a - seller-authored pointer exists and the payee matches the payee's key; add the - BOLT11 expiry check and disable unsupported kinds before the click. -3. **P1 correctness:** attestation dedupe by author+kind+d (revocation), add - `onlyVerified` to `useTradeReputation`, honor `escrow=required`, allow - ask-seller on an active listing, make Mark-sold atomic or verify the attestation. -4. **P1 state:** account-scope and read back receipts; fix the `request` tag; - NWC fallback + empty-preimage handling. -5. **P2:** outage states everywhere, native-only explanation on web, terminology - consolidation, and the untranslated values. +1. ~~P0 escrow fund-stranding: persist the token before publishing.~~ Done (item 9); + the remaining gap is a re-send/recover affordance for a saved pending escrow. +2. ~~P0 payee/pointer hardening + BOLT11 expiry.~~ Partly done (items 2, 6, 7); + still open: cross-check `payment_hash` and reject non-SATS amounts paid as sats. +3. **P1 correctness:** honor `escrow=required`, allow ask-seller on an active + listing, make Mark-sold atomic or verify the attestation, account-scope and + read back receipts, fix the `request` tag back-reference. +4. **P1 state:** NWC fallback (WebLN/Cashu on a normal failure) and empty-preimage + handling; outage states for trade/shipment/seller/reputation surfaces. +5. **P2:** native-only explanation on web, terminology consolidation, and the + untranslated values. Items tagged **[reported]** should be re-confirmed in source before a fix. diff --git a/src/components/EscrowRequestDialog.tsx b/src/components/EscrowRequestDialog.tsx index 25b25d1..3eb938d 100644 --- a/src/components/EscrowRequestDialog.tsx +++ b/src/components/EscrowRequestDialog.tsx @@ -14,6 +14,7 @@ import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs'; import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations'; import { useToast } from '@/hooks/useToast'; import { ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow'; +import { savePendingEscrow, clearPendingEscrow } from '@/lib/pendingEscrow'; import { Loader2, Lock } from 'lucide-react'; import { useTranslation } from '@/contexts/language'; @@ -62,14 +63,20 @@ export function EscrowRequestDialog({ } setBusy(true); + let mintedToken: string | undefined; try { const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: prefs.preferredMintUrl || undefined }); + mintedToken = token; + // Persist BEFORE publishing: if the request fails to send, the P2PK-locked + // funds stay recoverable from this device instead of being lost. + savePendingEscrow(listing, token, amountSats, lockDays); await publishRequest({ listing, attestor: sellerPubkey, cashuToken: token, note: t('marketUi.escrowRequest.requestNote', { amount: amountSats }), }); + clearPendingEscrow(listing); toast({ title: t('marketUi.escrowRequest.created'), description: t('marketUi.escrowRequest.createdBody', { amount: amountSats, days: lockDays, sellerName }), @@ -78,7 +85,9 @@ export function EscrowRequestDialog({ } catch (e) { toast({ title: t('marketUi.escrowRequest.createFailed'), - description: e instanceof Error ? e.message : t('marketUi.escrowRequest.somethingWrong'), + description: mintedToken + ? t('marketUi.escrowRequest.lockedNotSent') + : e instanceof Error ? e.message : t('marketUi.escrowRequest.somethingWrong'), variant: 'destructive', }); } finally { diff --git a/src/components/PayWithSats.tsx b/src/components/PayWithSats.tsx index d2e802d..1cf0d02 100644 --- a/src/components/PayWithSats.tsx +++ b/src/components/PayWithSats.tsx @@ -61,6 +61,9 @@ export function PayWithSats({ : lud16 ? 'lnaddress' : undefined; + // NUT-18 creq / clink are recognized but not payable yet — say so up front + // instead of enabling a button that fails after the click. + const unsupportedKind = effectiveKind === 'creq' || effectiveKind === 'clink'; // Reset transient state whenever the sheet is (re)opened. useEffect(() => { @@ -123,6 +126,11 @@ export function PayWithSats({ ) : isSelf ? (

{t('marketUi.pay.ownListing')}

+ ) : unsupportedKind ? ( +
+ +

{t('marketUi.pay.unsupported')}

+
) : (
{error && ( diff --git a/src/hooks/usePayPaymentPointer.ts b/src/hooks/usePayPaymentPointer.ts index 5d178e7..55a7917 100644 --- a/src/hooks/usePayPaymentPointer.ts +++ b/src/hooks/usePayPaymentPointer.ts @@ -18,7 +18,7 @@ import { useNWC } from '@/hooks/useNWCContext'; import { useWallet } from '@/hooks/useWallet'; import { useLightningAddress } from '@/hooks/useLightningAddress'; import { useCashu } from '@/hooks/wallet/useCashu'; -import { getSatoshisFromBolt11 } from '@/lib/bolt11'; +import { getSatoshisFromBolt11, isBolt11Expired } from '@/lib/bolt11'; import { buildReceipt, type PaymentReceipt } from '@/lib/receipts'; import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import type { PaymentKind } from '@/hooks/useMarketplaceOffers'; @@ -61,7 +61,13 @@ export function usePayPaymentPointer() { ); } - // 2) SECURITY: never auto-pay an invoice whose amount doesn't match the + // 2) Never pay an expired invoice — a lapsed one fails deep in the wallet + // with a confusing error, and a stale offer invoice is common. + if (isBolt11Expired(invoice)) { + throw new Error('This invoice has expired — ask the seller for a new one.'); + } + + // 3) SECURITY: never auto-pay an invoice whose amount doesn't match the // agreed price. A malicious seller can attach a raw BOLT11 or return an // inflated invoice from their own LNURL server, and an NWC/WebLN wallet // with a spend budget would pay it silently. `assertInvoiceAmount` @@ -78,7 +84,7 @@ export function usePayPaymentPointer() { } } - // 3) Pay it with the best available rail, capturing the PREIMAGE where the + // 4) Pay it with the best available rail, capturing the PREIMAGE where the // rail returns one — that is the verifiable receipt. let preimage: string | undefined; diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index 0cbcf6d..404afad 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -6,6 +6,19 @@ import { verifyZapReceipt } from '@/lib/zapReceipt'; import { verifyReceiptProof } from '@/lib/receipts'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { MARKETPLACE_ITEM_KIND } from '@/lib/types'; +import { onlyVerified } from '@/lib/verifyEvents'; + +/** Keep only the newest event per (author, kind, d) — addressable semantics. */ +function newestPerCoordinate(events: NostrEvent[]): NostrEvent[] { + const byKey = new Map(); + for (const ev of events) { + const d = ev.tags.find(([t]) => t === 'd')?.[1] ?? ''; + const key = `${ev.kind}:${ev.pubkey}:${d}`; + const current = byKey.get(key); + if (!current || ev.created_at > current.created_at) byKey.set(key, ev); + } + return [...byKey.values()]; +} /** * Trade attestations (completed deals) — built on the Attestations NIP @@ -213,15 +226,17 @@ export function useTradeReputation(pubkey: string | undefined) { if (!pubkey) return empty; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]); - const events = await nostr.query( + const events = onlyVerified(await nostr.query( [ { kinds: [TRADE_ATTESTATION_KIND], authors: [pubkey], limit: 200 }, { kinds: [TRADE_ATTESTATION_KIND], '#p': [pubkey], limit: 200 }, ], { signal }, - ); + )); - const unique = Array.from(new Map(events.map((e) => [e.id, e])).values()).filter( + // Newest per coordinate wins, so a newer `s=revoked` supersedes an older + // `valid` copy that a stale relay still serves. + const unique = newestPerCoordinate(events).filter( (e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked', ); @@ -350,8 +365,8 @@ export function usePendingTradeConfirmations(viewerPubkey: string | undefined) { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); const [naming, mine] = await Promise.all([ - nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }), - nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }), + nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified), + nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified), ]); const myCoords = new Set( @@ -399,11 +414,11 @@ export function useListingTrade( queryFn: async (c) => { if (!coord) return { attestations: [] as NostrEvent[], iConfirmed: false, counterparties: [] as string[] }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); - const events = await nostr.query( + const events = onlyVerified(await nostr.query( [{ kinds: [TRADE_ATTESTATION_KIND], '#a': [coord], limit: 50 }], { signal }, - ); - const attestations = events.filter( + )); + const attestations = newestPerCoordinate(events).filter( (e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked', ); const authorSet = new Set(attestations.map((e) => e.pubkey)); @@ -497,8 +512,8 @@ export function useIncomingTradeRequests(viewerPubkey: string | undefined) { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); const [requests, mine] = await Promise.all([ - nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }), - nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }), + nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified), + nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified), ]); // Requests the viewer already fulfilled (they attested that listing). diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 98351bd..bdbf465 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1893,6 +1893,7 @@ marketUi: { paid: 'Paid!', paidBody: 'Ask the seller to confirm the trade — two confirmations build both reputations.', noMethod: "{{sellerName}} hasn't set a payment method (no Lightning invoice or address). Message them to arrange payment.", + unsupported: 'This payment code (a Cashu request) is not supported yet. Ask the seller for a Lightning invoice or address.', ownListing: 'This is your own listing.', paying: 'Paying…', pay: 'Pay', @@ -2077,6 +2078,7 @@ marketUi: { amountLabel: 'Amount (sats)', daysLabel: 'Buyer refund after (days)', daysHint: "After this many days you can reclaim the funds if the seller hasn't delivered.", + lockedNotSent: 'Funds are locked in escrow and saved on this device, but the request did not send. Reopen the listing to retry. Do not pay again.', nativeOnly: 'Cashu escrow is available in the SkateSpots app only (not the web build).', lockAndRequest: 'Lock & request', }, diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 98746eb..0c65c57 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -1893,6 +1893,7 @@ marketUi: { paid: '¡Pagado!', paidBody: 'Pide al vendedor que confirme el trato: dos confirmaciones construyen la reputación de ambos.', noMethod: '{{sellerName}} no ha configurado un método de pago (ni factura ni dirección Lightning). Escríbele para acordar el pago.', + unsupported: 'Este código de pago (una solicitud Cashu) aún no es compatible. Pide al vendedor una factura o dirección Lightning.', ownListing: 'Este es tu propio anuncio.', paying: 'Pagando…', pay: 'Pagar', @@ -2077,6 +2078,7 @@ marketUi: { amountLabel: 'Importe (sats)', daysLabel: 'Reembolso del comprador tras (días)', daysHint: 'Tras estos días puedes recuperar los fondos si el vendedor no ha entregado.', + lockedNotSent: 'Los fondos están bloqueados en escrow y guardados en este dispositivo, pero la solicitud no se envió. Reabre el anuncio para reintentar. No pagues otra vez.', nativeOnly: 'El depósito en garantía Cashu solo está disponible en la app de SkateSpots (no en la versión web).', lockAndRequest: 'Bloquear y solicitar', }, diff --git a/src/lib/bolt11.ts b/src/lib/bolt11.ts index 8463cd5..148f008 100644 --- a/src/lib/bolt11.ts +++ b/src/lib/bolt11.ts @@ -115,3 +115,51 @@ export function getPaymentHashFromBolt11(invoice: string): string | null { return null; } } + +/** + * Absolute expiry (unix seconds) of a BOLT11 invoice, or null when it cannot be + * parsed. Uses the `x` tagged field when present, else BOLT11's 3600s default. + */ +export function getBolt11Expiry(invoice: string): number | null { + try { + const lower = invoice.trim().toLowerCase(); + const sep = lower.lastIndexOf('1'); + if (sep < 1 || sep + 7 > lower.length) return null; + const data = lower.slice(sep + 1, lower.length - 6); + const words = [...data].map((c) => CHARSET.indexOf(c)); + if (words.length < 7 || words.slice(0, 7).some((w) => w < 0)) return null; + + // 35-bit timestamp = the first 7 five-bit words. + let timestamp = 0; + for (let i = 0; i < 7; i += 1) timestamp = timestamp * 32 + words[i]; + + let expiry = 3600; + let i = 7; + while (i + 3 <= words.length) { + const tag = words[i]; + const len = (words[i + 1] << 5) | words[i + 2]; + const start = i + 3; + if (start + len > words.length) break; + if (tag === 6) { + // `x` = expiry, in seconds from the timestamp. + let value = 0; + for (let k = start; k < start + len; k += 1) value = value * 32 + words[k]; + expiry = value; + break; + } + i = start + len; + } + return timestamp + expiry; + } catch { + return null; + } +} + +/** True when a BOLT11 invoice is expired. Unparseable invoices return false. */ +export function isBolt11Expired( + invoice: string, + nowSec: number = Math.floor(Date.now() / 1000), +): boolean { + const expiry = getBolt11Expiry(invoice); + return expiry !== null && nowSec > expiry; +} diff --git a/src/lib/pendingEscrow.ts b/src/lib/pendingEscrow.ts new file mode 100644 index 0000000..6e13370 --- /dev/null +++ b/src/lib/pendingEscrow.ts @@ -0,0 +1,71 @@ +/** + * Device-local record of an escrow token that was minted (funds locked) but not + * yet confirmed on a relay. + * + * WHY: escrow creation mints P2PK-locked proofs and then publishes a kind-31872 + * request carrying the token. If the publish fails, the token lived only in an + * in-memory variable and the locked funds were unrecoverable. Persisting it here + * before the publish keeps the buyer able to recover/refund. + * + * The token is a bearer instrument, but it is P2PK-locked to the seller with the + * buyer's refund key — so storing it is safe and is exactly what protects the + * buyer if the send does not go through. + */ + +const STORAGE_KEY = 'skatespots:pending-escrow-v1'; + +export interface PendingEscrow { + token: string; + amountSats?: number; + lockDays?: number; + /** Unix seconds. */ + at: number; +} + +type Store = Record; + +function read(): Store { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as Store) : {}; + } catch { + return {}; + } +} + +function write(store: Store): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + /* storage full / unavailable — best-effort */ + } +} + +function keyFor(listing: { pubkey: string; identifier: string }): string { + return `30402:${listing.pubkey}:${listing.identifier}`; +} + +export function savePendingEscrow( + listing: { pubkey: string; identifier: string }, + token: string, + amountSats?: number, + lockDays?: number, +): void { + const store = read(); + store[keyFor(listing)] = { token, amountSats, lockDays, at: Math.floor(Date.now() / 1000) }; + write(store); +} + +export function getPendingEscrow( + listing: { pubkey: string; identifier: string }, +): PendingEscrow | undefined { + return read()[keyFor(listing)]; +} + +export function clearPendingEscrow(listing: { pubkey: string; identifier: string }): void { + const store = read(); + delete store[keyFor(listing)]; + write(store); +}