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:
parent
4b92759913
commit
077fb2d5d1
9 changed files with 201 additions and 28 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
71
src/lib/pendingEscrow.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue