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