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).
This commit is contained in:
SkateSpots 2026-10-09 23:14:29 +07:00
parent ac1b22674c
commit c71d176226
6 changed files with 42 additions and 6 deletions

View file

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

View file

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

View file

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

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, 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<PaymentReceipt> => {
async ({ pointer, kind, amountSats, expectedPaymentHash }: PayPointerArgs): Promise<PaymentReceipt> => {
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;

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.",
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…',

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.',
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…',