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.
This commit is contained in:
SkateSpots 2026-10-09 23:00:35 +07:00
parent 4b92759913
commit 077fb2d5d1
9 changed files with 201 additions and 28 deletions

View file

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

View file

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

View file

@ -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({
</div>
) : isSelf ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('marketUi.pay.ownListing')}</p>
) : unsupportedKind ? (
<div className="space-y-2 py-4 text-center">
<AlertTriangle className="mx-auto h-8 w-8 text-amber-400" />
<p className="text-sm text-muted-foreground">{t('marketUi.pay.unsupported')}</p>
</div>
) : (
<div className="space-y-3">
{error && (

View file

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

View file

@ -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<string, NostrEvent>();
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).

View file

@ -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',
},

View file

@ -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',
},

View file

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

71
src/lib/pendingEscrow.ts Normal file
View file

@ -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<string, PendingEscrow>;
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);
}