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