feat(barter): optional good-faith deposit with a verifiable receipt

A pure swap has no invoice, so ListingTradeActions now offers a 'Good-faith
deposit' input for price_type=trade listings. Paying it stores a deposit-slot
receipt (saveReceipt(coord, r, 'deposit')); the buyer's confirm attaches it as
['proof','deposit',preimage,payment_hash] with method=trade, so a barter carries
a verifiable COST without pretending the goods were proven.
This commit is contained in:
SkateSpots 2026-10-01 18:39:30 +07:00
parent e7c6bb1ece
commit 4f0416c23d
2 changed files with 83 additions and 13 deletions

View file

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
@ -40,6 +41,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const navigate = useNavigate();
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
@ -48,6 +50,8 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const [showEscrow, setShowEscrow] = useState(false);
const [showPay, setShowPay] = useState(false);
const [showDeposit, setShowDeposit] = useState(false);
const [depositAmount, setDepositAmount] = useState('1000');
const isOwnListing = user?.pubkey === item.pubkey;
const isSold = item.status === 'sold';
@ -90,6 +94,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
typeof buyAmountSats === 'number' &&
buyAmountSats > 0;
// Barter: a swap has no invoice, so an optional refundable deposit gives it a
// verifiable cost (and a receipt) without pretending the goods were proven.
const canDeposit =
!!user && !isOwnListing && !isWTB && !isSold && !iConfirmedTrade && item.priceType === 'trade';
const sellerEscrowPubkey =
typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string'
? ((metadata as Record<string, unknown>).cashu_escrow as string)
@ -118,29 +127,37 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const handleConfirmTrade = async () => {
if (!user) return;
// Attach the Lightning receipt captured at Pay time, if any — this is what
// makes the trade payment-VERIFIED (sha256(preimage) === payment_hash).
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
// 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 paymentProof =
receipt?.preimage && receipt.paymentHash
? { preimage: receipt.preimage, paymentHash: receipt.paymentHash }
: undefined;
const depositReceipt = getReceipt(coord, 'deposit');
const depositProof =
depositReceipt?.preimage && depositReceipt.paymentHash
? { preimage: depositReceipt.preimage, paymentHash: depositReceipt.paymentHash }
: undefined;
try {
await publishTrade({
listing: listingRef,
role: 'bought',
counterparty: item.pubkey,
method: paymentProof ? 'lightning' : undefined,
method: paymentProof ? 'lightning' : depositProof ? 'trade' : undefined,
amountSats: buyAmountSats ?? undefined,
paymentProof,
depositProof,
});
clearReceipt(coord);
clearReceipt(coord, 'deposit');
toast({
title: 'Trade confirmed',
description: paymentProof
? 'Payment receipt attached — this counts as payment-verified.'
: 'Reputation updated for both of you.',
description:
paymentProof || depositProof
? 'A verifiable receipt was attached — this counts toward verified trades.'
: 'Reputation updated for both of you.',
});
} catch (e) {
toast({
@ -295,6 +312,52 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
<HandoffHelper item={item} />
)}
{/* Barter: optional good-faith deposit (a verifiable cost for a swap). */}
{canDeposit && (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
Good-faith deposit (optional)
</p>
<div className="flex gap-2">
<Input
type="number"
inputMode="numeric"
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
className="h-9 flex-1 border-white/15 bg-white/5 text-sm text-white"
/>
<Button
size="sm"
onClick={() => setShowDeposit(true)}
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
Pay deposit
</Button>
</div>
<p className="text-[10px] leading-relaxed text-gray-600">
A swap has no invoice, so this is how it gets a{' '}
<strong className="text-gray-400">verifiable cost</strong>. Refund on delivery (or use
Cashu escrow for an automatic refund). ⚠️ Proves commitment, not delivery.
</p>
</div>
)}
{showDeposit && (
<PayWithSats
open
onOpenChange={(open) => { if (!open) setShowDeposit(false); }}
sellerPubkey={item.pubkey}
amountSats={Number(depositAmount) > 0 ? Number(depositAmount) : undefined}
onPaid={(receipt) => {
if (receipt) saveReceipt(coord, receipt, 'deposit');
toast({
title: 'Deposit paid',
description: 'Saved — it will be attached when the trade is confirmed.',
});
}}
/>
)}
{/* Shipping logistics — self-reported, both sides. */}
{user && !isWTB && (item.fulfilment === 'ships' || item.fulfilment === 'both') && (
<ShippingStatusCard item={item} />

View file

@ -63,6 +63,13 @@ export function verifyReceiptProof(preimage?: string, paymentHash?: string): boo
*/
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>;
@ -71,24 +78,24 @@ function loadAll(): Record<string, PaymentReceipt> {
}
}
export function saveReceipt(coord: string, receipt: PaymentReceipt): void {
export function saveReceipt(coord: string, receipt: PaymentReceipt, slot: ReceiptSlot = 'payment'): void {
try {
const all = loadAll();
all[coord] = receipt;
all[keyFor(coord, slot)] = receipt;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* storage unavailable — the receipt just isn't carried forward */
}
}
export function getReceipt(coord: string): PaymentReceipt | undefined {
return loadAll()[coord];
export function getReceipt(coord: string, slot: ReceiptSlot = 'payment'): PaymentReceipt | undefined {
return loadAll()[keyFor(coord, slot)];
}
export function clearReceipt(coord: string): void {
export function clearReceipt(coord: string, slot: ReceiptSlot = 'payment'): void {
try {
const all = loadAll();
delete all[coord];
delete all[keyFor(coord, slot)];
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* ignore */