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:
parent
ac1b22674c
commit
c71d176226
6 changed files with 42 additions and 6 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
Loading…
Reference in a new issue