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:
parent
b48f0e53ee
commit
7a5ec82f84
4 changed files with 27 additions and 18 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in a new issue