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
|
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
|
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.)
|
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
|
## 7. Recommended order
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
||||||
currency={payFor.latest.currency}
|
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, 'payment', user?.pubkey ?? '');
|
||||||
setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`));
|
setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
!isSold &&
|
!isSold &&
|
||||||
!isExpired &&
|
!isExpired &&
|
||||||
!iConfirmedTrade &&
|
!iConfirmedTrade &&
|
||||||
|
// A seller who requires escrow must not be paid directly.
|
||||||
|
item.escrow !== 'required' &&
|
||||||
item.priceType === 'fixed' &&
|
item.priceType === 'fixed' &&
|
||||||
typeof buyAmountSats === 'number' &&
|
typeof buyAmountSats === 'number' &&
|
||||||
buyAmountSats > 0;
|
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
|
// Attach the Lightning receipts captured at Pay time, if any — this is what
|
||||||
// makes the trade payment-VERIFIED (sha256(preimage) === payment_hash), and
|
// makes the trade payment-VERIFIED (sha256(preimage) === payment_hash), and
|
||||||
// gives a barter a verifiable DEPOSIT cost.
|
// gives a barter a verifiable DEPOSIT cost.
|
||||||
const receipt = getReceipt(coord);
|
const receipt = getReceipt(coord, 'payment', user?.pubkey ?? '');
|
||||||
const paymentProof =
|
const paymentProof =
|
||||||
receipt?.preimage && receipt.paymentHash
|
receipt?.preimage && receipt.paymentHash
|
||||||
? { preimage: receipt.preimage, paymentHash: receipt.paymentHash }
|
? { preimage: receipt.preimage, paymentHash: receipt.paymentHash }
|
||||||
: undefined;
|
: undefined;
|
||||||
const depositReceipt = getReceipt(coord, 'deposit');
|
const depositReceipt = getReceipt(coord, 'deposit', user?.pubkey ?? '');
|
||||||
const depositProof =
|
const depositProof =
|
||||||
depositReceipt?.preimage && depositReceipt.paymentHash
|
depositReceipt?.preimage && depositReceipt.paymentHash
|
||||||
? { preimage: depositReceipt.preimage, paymentHash: depositReceipt.paymentHash }
|
? { preimage: depositReceipt.preimage, paymentHash: depositReceipt.paymentHash }
|
||||||
|
|
@ -154,8 +156,8 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
paymentProof,
|
paymentProof,
|
||||||
depositProof,
|
depositProof,
|
||||||
});
|
});
|
||||||
clearReceipt(coord);
|
clearReceipt(coord, 'payment', user?.pubkey ?? '');
|
||||||
clearReceipt(coord, 'deposit');
|
clearReceipt(coord, 'deposit', user?.pubkey ?? '');
|
||||||
toast({
|
toast({
|
||||||
title: t('marketUi.tradeActions.tradeConfirmed'),
|
title: t('marketUi.tradeActions.tradeConfirmed'),
|
||||||
description:
|
description:
|
||||||
|
|
@ -251,7 +253,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
|
{/* 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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -350,7 +352,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
sellerPubkey={item.pubkey}
|
sellerPubkey={item.pubkey}
|
||||||
amountSats={Number(depositAmount) > 0 ? Number(depositAmount) : undefined}
|
amountSats={Number(depositAmount) > 0 ? Number(depositAmount) : undefined}
|
||||||
onPaid={(receipt) => {
|
onPaid={(receipt) => {
|
||||||
if (receipt) saveReceipt(coord, receipt, 'deposit');
|
if (receipt) saveReceipt(coord, receipt, 'deposit', user?.pubkey ?? '');
|
||||||
toast({
|
toast({
|
||||||
title: t('marketUi.tradeActions.depositPaid'),
|
title: t('marketUi.tradeActions.depositPaid'),
|
||||||
description: t('marketUi.tradeActions.depositPaidBody'),
|
description: t('marketUi.tradeActions.depositPaidBody'),
|
||||||
|
|
@ -373,7 +375,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
amountSats={buyAmountSats ?? undefined}
|
amountSats={buyAmountSats ?? undefined}
|
||||||
onPaid={(receipt) => {
|
onPaid={(receipt) => {
|
||||||
if (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({
|
toast({
|
||||||
title: t('marketUi.tradeActions.paid'),
|
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. */
|
/** A trade can carry two receipts: the payment and (for barter) a deposit. */
|
||||||
export type ReceiptSlot = 'payment' | 'deposit';
|
export type ReceiptSlot = 'payment' | 'deposit';
|
||||||
|
|
||||||
function keyFor(coord: string, slot: ReceiptSlot): string {
|
|
||||||
return slot === 'deposit' ? `${coord}#deposit` : coord;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAll(): Record<string, PaymentReceipt> {
|
function loadAll(): Record<string, PaymentReceipt> {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, PaymentReceipt>;
|
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 {
|
try {
|
||||||
const all = loadAll();
|
const all = loadAll();
|
||||||
all[keyFor(coord, slot)] = receipt;
|
all[keyFor(coord, slot, owner)] = receipt;
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||||
} catch {
|
} catch {
|
||||||
/* storage unavailable — the receipt just isn't carried forward */
|
/* storage unavailable — the receipt just isn't carried forward */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReceipt(coord: string, slot: ReceiptSlot = 'payment'): PaymentReceipt | undefined {
|
export function getReceipt(coord: string, slot: ReceiptSlot = 'payment', owner = ''): PaymentReceipt | undefined {
|
||||||
return loadAll()[keyFor(coord, slot)];
|
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 {
|
try {
|
||||||
const all = loadAll();
|
const all = loadAll();
|
||||||
delete all[keyFor(coord, slot)];
|
delete all[keyFor(coord, slot, owner)];
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue