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:
parent
e7c6bb1ece
commit
4f0416c23d
2 changed files with 83 additions and 13 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in a new issue