fix(marketplace): escrow-required guard, ask-seller on active, scoped receipts

Correctness batch from MARKETPLACE_FLOW_AUDIT.md:
- canBuy is disabled when the seller requires escrow (item.escrow ===
  'required'), so a direct Buy-with-sats cannot bypass it.
- ask-seller (31872) is available on an active listing, not only once sold,
  so a buyer who paid off-app is no longer at a dead end.
- payment receipts (lib/receipts.ts) are scoped to the payer's pubkey, so a
  stale or foreign receipt cannot be attached to a trade by another account
  viewing the same listing.
This commit is contained in:
SkateSpots 2026-10-10 09:08:52 +07:00
parent b48f0e53ee
commit 7a5ec82f84
4 changed files with 27 additions and 18 deletions

View file

@ -218,6 +218,10 @@ Second batch (payment, attestation, escrow):
documented in NIP.md; the buyer's escrow dialog includes that mint in the
picker and prefers it, so the buyer locks at a mint the seller can redeem
from. (Still open: seller-side receive from a mint they do not already have.)
13. **Correctness guards:** fixed-price Buy is disabled when the seller requires
escrow (`item.escrow === 'required'`); ask-seller is available on an active
listing (not only once sold); payment receipts are scoped to the payer's
pubkey, so a stale/foreign receipt cannot be attached by another account.
## 7. Recommended order

View file

@ -338,7 +338,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
currency={payFor.latest.currency}
onPaid={(receipt) => {
// Carry the verifiable receipt to the confirm step (same coord key).
if (receipt) saveReceipt(payFor.listingCoord, receipt);
if (receipt) saveReceipt(payFor.listingCoord, receipt, 'payment', user?.pubkey ?? '');
setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`));
}}
/>

View file

@ -94,6 +94,8 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
!isSold &&
!isExpired &&
!iConfirmedTrade &&
// A seller who requires escrow must not be paid directly.
item.escrow !== 'required' &&
item.priceType === 'fixed' &&
typeof buyAmountSats === 'number' &&
buyAmountSats > 0;
@ -134,12 +136,12 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
// Attach the Lightning receipts captured at Pay time, if any — this is what
// makes the trade payment-VERIFIED (sha256(preimage) === payment_hash), and
// gives a barter a verifiable DEPOSIT cost.
const receipt = getReceipt(coord);
const receipt = getReceipt(coord, 'payment', user?.pubkey ?? '');
const paymentProof =
receipt?.preimage && receipt.paymentHash
? { preimage: receipt.preimage, paymentHash: receipt.paymentHash }
: undefined;
const depositReceipt = getReceipt(coord, 'deposit');
const depositReceipt = getReceipt(coord, 'deposit', user?.pubkey ?? '');
const depositProof =
depositReceipt?.preimage && depositReceipt.paymentHash
? { preimage: depositReceipt.preimage, paymentHash: depositReceipt.paymentHash }
@ -154,8 +156,8 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
paymentProof,
depositProof,
});
clearReceipt(coord);
clearReceipt(coord, 'deposit');
clearReceipt(coord, 'payment', user?.pubkey ?? '');
clearReceipt(coord, 'deposit', user?.pubkey ?? '');
toast({
title: t('marketUi.tradeActions.tradeConfirmed'),
description:
@ -251,7 +253,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
)}
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
{!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
{!canEscrow && user && !isOwnListing && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
<Button
size="sm"
variant="outline"
@ -350,7 +352,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
sellerPubkey={item.pubkey}
amountSats={Number(depositAmount) > 0 ? Number(depositAmount) : undefined}
onPaid={(receipt) => {
if (receipt) saveReceipt(coord, receipt, 'deposit');
if (receipt) saveReceipt(coord, receipt, 'deposit', user?.pubkey ?? '');
toast({
title: t('marketUi.tradeActions.depositPaid'),
description: t('marketUi.tradeActions.depositPaidBody'),
@ -373,7 +375,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
amountSats={buyAmountSats ?? undefined}
onPaid={(receipt) => {
if (receipt) {
saveReceipt(`${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`, receipt);
saveReceipt(`${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`, receipt, 'payment', user?.pubkey ?? '');
}
toast({
title: t('marketUi.tradeActions.paid'),

View file

@ -66,10 +66,6 @@ const STORAGE_KEY = 'skatespots:payment-receipts-v1';
/** A trade can carry two receipts: the payment and (for barter) a deposit. */
export type ReceiptSlot = 'payment' | 'deposit';
function keyFor(coord: string, slot: ReceiptSlot): string {
return slot === 'deposit' ? `${coord}#deposit` : coord;
}
function loadAll(): Record<string, PaymentReceipt> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, PaymentReceipt>;
@ -78,24 +74,31 @@ function loadAll(): Record<string, PaymentReceipt> {
}
}
export function saveReceipt(coord: string, receipt: PaymentReceipt, slot: ReceiptSlot = 'payment'): void {
function keyFor(coord: string, slot: ReceiptSlot, owner: string): string {
const base = slot === 'deposit' ? `${coord}#deposit` : coord;
// Scope to the payer: a receipt must never be attached to a trade by a
// different account that happens to view the same listing.
return owner ? `${owner}|${base}` : base;
}
export function saveReceipt(coord: string, receipt: PaymentReceipt, slot: ReceiptSlot = 'payment', owner = ''): void {
try {
const all = loadAll();
all[keyFor(coord, slot)] = receipt;
all[keyFor(coord, slot, owner)] = receipt;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* storage unavailable — the receipt just isn't carried forward */
}
}
export function getReceipt(coord: string, slot: ReceiptSlot = 'payment'): PaymentReceipt | undefined {
return loadAll()[keyFor(coord, slot)];
export function getReceipt(coord: string, slot: ReceiptSlot = 'payment', owner = ''): PaymentReceipt | undefined {
return loadAll()[keyFor(coord, slot, owner)];
}
export function clearReceipt(coord: string, slot: ReceiptSlot = 'payment'): void {
export function clearReceipt(coord: string, slot: ReceiptSlot = 'payment', owner = ''): void {
try {
const all = loadAll();
delete all[keyFor(coord, slot)];
delete all[keyFor(coord, slot, owner)];
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* ignore */