From 4f0416c23d1cdfb7e22f8593ccd9965e42e4f791 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 18:39:30 +0700 Subject: [PATCH] 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. --- src/components/ListingTradeActions.tsx | 77 +++++++++++++++++++++++--- src/lib/receipts.ts | 19 +++++-- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/components/ListingTradeActions.tsx b/src/components/ListingTradeActions.tsx index a079d6b..9eb83c2 100644 --- a/src/components/ListingTradeActions.tsx +++ b/src/components/ListingTradeActions.tsx @@ -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 | undefined)?.cashu_escrow === 'string' ? ((metadata as Record).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 }) { )} + {/* Barter: optional good-faith deposit (a verifiable cost for a swap). */} + {canDeposit && ( +
+

+ Good-faith deposit (optional) +

+
+ setDepositAmount(e.target.value)} + className="h-9 flex-1 border-white/15 bg-white/5 text-sm text-white" + /> + +
+

+ A swap has no invoice, so this is how it gets a{' '} + verifiable cost. Refund on delivery (or use + Cashu escrow for an automatic refund). ⚠️ Proves commitment, not delivery. +

+
+ )} + + {showDeposit && ( + { 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') && ( diff --git a/src/lib/receipts.ts b/src/lib/receipts.ts index c262f64..e845654 100644 --- a/src/lib/receipts.ts +++ b/src/lib/receipts.ts @@ -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 { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; @@ -71,24 +78,24 @@ function loadAll(): Record { } } -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 */