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
|
buyer pick from their trusted mints; a mint whose NUT-06 info advertises no
|
||||||
NUT-11 (P2PK) support is blocked with a clear message
|
NUT-11 (P2PK) support is blocked with a clear message
|
||||||
(`cashuEscrow.mintSupportsP2PK`), instead of failing deep in minting.
|
(`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
|
## 7. Recommended order
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -331,9 +331,11 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
||||||
open={!!payFor}
|
open={!!payFor}
|
||||||
onOpenChange={(o) => { if (!o) setPayFor(null); }}
|
onOpenChange={(o) => { if (!o) setPayFor(null); }}
|
||||||
sellerPubkey={payFor.latest.seller}
|
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}
|
pointer={payFor.latest.payment}
|
||||||
pointerKind={payFor.latest.paymentKind}
|
pointerKind={payFor.latest.paymentKind}
|
||||||
|
paymentHash={payFor.latest.paymentHash}
|
||||||
|
currency={payFor.latest.currency}
|
||||||
onPaid={(receipt) => {
|
onPaid={(receipt) => {
|
||||||
// Carry the verifiable receipt to the confirm step (same coord key).
|
// Carry the verifiable receipt to the confirm step (same coord key).
|
||||||
if (receipt) saveReceipt(payFor.listingCoord, receipt);
|
if (receipt) saveReceipt(payFor.listingCoord, receipt);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ interface PayWithSatsProps {
|
||||||
/** Pointer attached by the seller on accept, if any. */
|
/** Pointer attached by the seller on accept, if any. */
|
||||||
pointer?: string;
|
pointer?: string;
|
||||||
pointerKind?: PaymentKind;
|
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). */
|
/** Called once a payment is sent (receipt carries the verifiable preimage, if any). */
|
||||||
onPaid?: (receipt?: PaymentReceipt) => void;
|
onPaid?: (receipt?: PaymentReceipt) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -42,6 +46,8 @@ export function PayWithSats({
|
||||||
amountSats,
|
amountSats,
|
||||||
pointer,
|
pointer,
|
||||||
pointerKind,
|
pointerKind,
|
||||||
|
paymentHash,
|
||||||
|
currency,
|
||||||
onPaid,
|
onPaid,
|
||||||
}: PayWithSatsProps) {
|
}: PayWithSatsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -64,6 +70,9 @@ export function PayWithSats({
|
||||||
// NUT-18 creq / clink are recognized but not payable yet — say so up front
|
// NUT-18 creq / clink are recognized but not payable yet — say so up front
|
||||||
// instead of enabling a button that fails after the click.
|
// instead of enabling a button that fails after the click.
|
||||||
const unsupportedKind = effectiveKind === 'creq' || effectiveKind === 'clink';
|
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.
|
// Reset transient state whenever the sheet is (re)opened.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -77,7 +86,7 @@ export function PayWithSats({
|
||||||
if (!effectiveKind) return;
|
if (!effectiveKind) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const receipt = await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats });
|
const receipt = await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats, expectedPaymentHash: paymentHash });
|
||||||
setPaid(true);
|
setPaid(true);
|
||||||
toast({
|
toast({
|
||||||
title: t('marketUi.pay.sent'),
|
title: t('marketUi.pay.sent'),
|
||||||
|
|
@ -126,6 +135,11 @@ export function PayWithSats({
|
||||||
</div>
|
</div>
|
||||||
) : isSelf ? (
|
) : isSelf ? (
|
||||||
<p className="py-4 text-center text-sm text-muted-foreground">{t('marketUi.pay.ownListing')}</p>
|
<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 ? (
|
) : unsupportedKind ? (
|
||||||
<div className="space-y-2 py-4 text-center">
|
<div className="space-y-2 py-4 text-center">
|
||||||
<AlertTriangle className="mx-auto h-8 w-8 text-amber-400" />
|
<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 { useWallet } from '@/hooks/useWallet';
|
||||||
import { useLightningAddress } from '@/hooks/useLightningAddress';
|
import { useLightningAddress } from '@/hooks/useLightningAddress';
|
||||||
import { useCashu } from '@/hooks/wallet/useCashu';
|
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 { buildReceipt, type PaymentReceipt } from '@/lib/receipts';
|
||||||
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
||||||
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
|
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
|
||||||
|
|
@ -29,6 +29,8 @@ export interface PayPointerArgs {
|
||||||
kind: PaymentKind;
|
kind: PaymentKind;
|
||||||
/** Required to pay a Lightning address (an invoice carries its own amount). */
|
/** Required to pay a Lightning address (an invoice carries its own amount). */
|
||||||
amountSats?: number;
|
amountSats?: number;
|
||||||
|
/** The seller's committed payment hash on the accepted offer, if any. */
|
||||||
|
expectedPaymentHash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePayPaymentPointer() {
|
export function usePayPaymentPointer() {
|
||||||
|
|
@ -39,7 +41,7 @@ export function usePayPaymentPointer() {
|
||||||
const [isPaying, setIsPaying] = useState(false);
|
const [isPaying, setIsPaying] = useState(false);
|
||||||
|
|
||||||
const pay = useCallback(
|
const pay = useCallback(
|
||||||
async ({ pointer, kind, amountSats }: PayPointerArgs): Promise<PaymentReceipt> => {
|
async ({ pointer, kind, amountSats, expectedPaymentHash }: PayPointerArgs): Promise<PaymentReceipt> => {
|
||||||
setIsPaying(true);
|
setIsPaying(true);
|
||||||
try {
|
try {
|
||||||
// 1) Resolve the pointer to a BOLT11 invoice.
|
// 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.');
|
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
|
// 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
|
// inflated invoice from their own LNURL server, and an NWC/WebLN wallet
|
||||||
// with a spend budget would pay it silently. `assertInvoiceAmount`
|
// 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.
|
// rail returns one — that is the verifiable receipt.
|
||||||
let preimage: string | undefined;
|
let preimage: string | undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1893,6 +1893,7 @@ marketUi: {
|
||||||
paid: 'Paid!',
|
paid: 'Paid!',
|
||||||
paidBody: 'Ask the seller to confirm the trade — two confirmations build both reputations.',
|
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.",
|
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.',
|
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.',
|
ownListing: 'This is your own listing.',
|
||||||
paying: 'Paying…',
|
paying: 'Paying…',
|
||||||
|
|
|
||||||
|
|
@ -1893,6 +1893,7 @@ marketUi: {
|
||||||
paid: '¡Pagado!',
|
paid: '¡Pagado!',
|
||||||
paidBody: 'Pide al vendedor que confirme el trato: dos confirmaciones construyen la reputación de ambos.',
|
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.',
|
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.',
|
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.',
|
ownListing: 'Este es tu propio anuncio.',
|
||||||
paying: 'Pagando…',
|
paying: 'Pagando…',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue