From c71d176226069bb8b50e30269d2404a2abac89a7 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 9 Oct 2026 23:14:29 +0700 Subject: [PATCH] fix(marketplace): verify payment_hash and never pay fiat as sats - usePayPaymentPointer now checks the seller's committed payment_hash (from the accepted offer) against the invoice actually paid; a swapped pointer is rejected before any payment. - PayWithSats no longer offers an in-app sats Pay button for a non-SATS offer: a fiat amount is never paid as if it were sats. New marketUi.pay.nonSats (en/es). - ListingOffersPanel passes paymentHash + currency through. Docs updated (still open: NWC->WebLN/Cashu fallback, empty preimage). --- MARKETPLACE_FLOW_AUDIT.md | 6 ++++++ src/components/ListingOffersPanel.tsx | 4 +++- src/components/PayWithSats.tsx | 16 +++++++++++++++- src/hooks/usePayPaymentPointer.ts | 20 ++++++++++++++++---- src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/MARKETPLACE_FLOW_AUDIT.md b/MARKETPLACE_FLOW_AUDIT.md index 8f0ea57..a009997 100644 --- a/MARKETPLACE_FLOW_AUDIT.md +++ b/MARKETPLACE_FLOW_AUDIT.md @@ -204,6 +204,12 @@ Second batch (payment, attestation, escrow): buyer pick from their trusted mints; a mint whose NUT-06 info advertises no NUT-11 (P2PK) support is blocked with a clear message (`cashuEscrow.mintSupportsP2PK`), instead of failing deep in minting. +11. **Payment cross-checks:** the buyer now verifies the seller's committed + `payment_hash` on the accepted offer against the invoice actually paid + (a swapped pointer is rejected), and a **non-SATS** offer no longer renders + an in-app sats Pay button — a fiat amount can never be paid as if it were + sats. (Still open: WebLN/Cashu fallback after a normal NWC failure, and the + empty-preimage case.) ## 7. Recommended order diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx index 31762e4..ee016da 100644 --- a/src/components/ListingOffersPanel.tsx +++ b/src/components/ListingOffersPanel.tsx @@ -331,9 +331,11 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i open={!!payFor} onOpenChange={(o) => { if (!o) setPayFor(null); }} sellerPubkey={payFor.latest.seller} - amountSats={payFor.latest.amount} + amountSats={payFor.latest.currency && payFor.latest.currency.toUpperCase() !== 'SATS' ? undefined : payFor.latest.amount} pointer={payFor.latest.payment} pointerKind={payFor.latest.paymentKind} + paymentHash={payFor.latest.paymentHash} + currency={payFor.latest.currency} onPaid={(receipt) => { // Carry the verifiable receipt to the confirm step (same coord key). if (receipt) saveReceipt(payFor.listingCoord, receipt); diff --git a/src/components/PayWithSats.tsx b/src/components/PayWithSats.tsx index 1cf0d02..e4975a7 100644 --- a/src/components/PayWithSats.tsx +++ b/src/components/PayWithSats.tsx @@ -31,6 +31,10 @@ interface PayWithSatsProps { /** Pointer attached by the seller on accept, if any. */ pointer?: string; pointerKind?: PaymentKind; + /** The seller's committed payment hash on the accepted offer, if any. */ + paymentHash?: string; + /** The agreed amount's currency (in-app payment is sats only). */ + currency?: string; /** Called once a payment is sent (receipt carries the verifiable preimage, if any). */ onPaid?: (receipt?: PaymentReceipt) => void; } @@ -42,6 +46,8 @@ export function PayWithSats({ amountSats, pointer, pointerKind, + paymentHash, + currency, onPaid, }: PayWithSatsProps) { const { t } = useTranslation(); @@ -64,6 +70,9 @@ export function PayWithSats({ // 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'; + // In-app payment is sats only; an offer priced in another currency must be + // settled directly (we must not pay a fiat number as if it were sats). + const nonSats = !!currency && currency.toUpperCase() !== 'SATS'; // Reset transient state whenever the sheet is (re)opened. useEffect(() => { @@ -77,7 +86,7 @@ export function PayWithSats({ if (!effectiveKind) return; setError(null); try { - const receipt = await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats }); + const receipt = await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats, expectedPaymentHash: paymentHash }); setPaid(true); toast({ title: t('marketUi.pay.sent'), @@ -126,6 +135,11 @@ export function PayWithSats({ ) : isSelf ? (

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

+ ) : nonSats ? ( +
+ +

{t('marketUi.pay.nonSats', { currency })}

+
) : unsupportedKind ? (
diff --git a/src/hooks/usePayPaymentPointer.ts b/src/hooks/usePayPaymentPointer.ts index 55a7917..1c269b2 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, isBolt11Expired } from '@/lib/bolt11'; +import { getSatoshisFromBolt11, getPaymentHashFromBolt11, isBolt11Expired } from '@/lib/bolt11'; import { buildReceipt, type PaymentReceipt } from '@/lib/receipts'; import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import type { PaymentKind } from '@/hooks/useMarketplaceOffers'; @@ -29,6 +29,8 @@ export interface PayPointerArgs { kind: PaymentKind; /** Required to pay a Lightning address (an invoice carries its own amount). */ amountSats?: number; + /** The seller's committed payment hash on the accepted offer, if any. */ + expectedPaymentHash?: string; } export function usePayPaymentPointer() { @@ -39,7 +41,7 @@ export function usePayPaymentPointer() { const [isPaying, setIsPaying] = useState(false); const pay = useCallback( - async ({ pointer, kind, amountSats }: PayPointerArgs): Promise => { + async ({ pointer, kind, amountSats, expectedPaymentHash }: PayPointerArgs): Promise => { setIsPaying(true); try { // 1) Resolve the pointer to a BOLT11 invoice. @@ -67,7 +69,17 @@ export function usePayPaymentPointer() { 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 + // 3) If the seller committed to a payment hash on the accepted offer, the + // invoice we pay MUST resolve to it (the pointer could be swapped). + const expectedHash = expectedPaymentHash?.trim().toLowerCase(); + if (expectedHash) { + const actualHash = getPaymentHashFromBolt11(invoice); + if (!actualHash || actualHash !== expectedHash) { + throw new Error('The invoice does not match the payment hash the seller agreed — do not pay.'); + } + } + + // 4) 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` @@ -84,7 +96,7 @@ export function usePayPaymentPointer() { } } - // 4) Pay it with the best available rail, capturing the PREIMAGE where the + // 5) 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/i18n/en.ts b/src/i18n/en.ts index f998f77..5d60e20 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.", + nonSats: 'This offer is priced in {{currency}}. In-app payment is sats only. Settle directly with the seller.', 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…', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 4a8cbac..2b85994 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.', + nonSats: 'Esta oferta está en {{currency}}. El pago en la app es solo en sats. Llega a un acuerdo directamente con el vendedor.', 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…',