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 { useNavigate } from 'react-router-dom';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
@ -40,6 +41,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
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: trade } = useListingTrade(listingRef, user?.pubkey);
|
||||||
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
||||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||||
|
|
@ -48,6 +50,8 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
|
|
||||||
const [showEscrow, setShowEscrow] = useState(false);
|
const [showEscrow, setShowEscrow] = useState(false);
|
||||||
const [showPay, setShowPay] = useState(false);
|
const [showPay, setShowPay] = useState(false);
|
||||||
|
const [showDeposit, setShowDeposit] = useState(false);
|
||||||
|
const [depositAmount, setDepositAmount] = useState('1000');
|
||||||
|
|
||||||
const isOwnListing = user?.pubkey === item.pubkey;
|
const isOwnListing = user?.pubkey === item.pubkey;
|
||||||
const isSold = item.status === 'sold';
|
const isSold = item.status === 'sold';
|
||||||
|
|
@ -90,6 +94,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
typeof buyAmountSats === 'number' &&
|
typeof buyAmountSats === 'number' &&
|
||||||
buyAmountSats > 0;
|
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 =
|
const sellerEscrowPubkey =
|
||||||
typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string'
|
typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string'
|
||||||
? ((metadata as Record<string, unknown>).cashu_escrow as string)
|
? ((metadata as Record<string, unknown>).cashu_escrow as string)
|
||||||
|
|
@ -118,28 +127,36 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
|
|
||||||
const handleConfirmTrade = async () => {
|
const handleConfirmTrade = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
// Attach the Lightning receipt 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).
|
// makes the trade payment-VERIFIED (sha256(preimage) === payment_hash), and
|
||||||
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
|
// gives a barter a verifiable DEPOSIT cost.
|
||||||
const receipt = getReceipt(coord);
|
const receipt = getReceipt(coord);
|
||||||
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 depositProof =
|
||||||
|
depositReceipt?.preimage && depositReceipt.paymentHash
|
||||||
|
? { preimage: depositReceipt.preimage, paymentHash: depositReceipt.paymentHash }
|
||||||
|
: undefined;
|
||||||
try {
|
try {
|
||||||
await publishTrade({
|
await publishTrade({
|
||||||
listing: listingRef,
|
listing: listingRef,
|
||||||
role: 'bought',
|
role: 'bought',
|
||||||
counterparty: item.pubkey,
|
counterparty: item.pubkey,
|
||||||
method: paymentProof ? 'lightning' : undefined,
|
method: paymentProof ? 'lightning' : depositProof ? 'trade' : undefined,
|
||||||
amountSats: buyAmountSats ?? undefined,
|
amountSats: buyAmountSats ?? undefined,
|
||||||
paymentProof,
|
paymentProof,
|
||||||
|
depositProof,
|
||||||
});
|
});
|
||||||
clearReceipt(coord);
|
clearReceipt(coord);
|
||||||
|
clearReceipt(coord, 'deposit');
|
||||||
toast({
|
toast({
|
||||||
title: 'Trade confirmed',
|
title: 'Trade confirmed',
|
||||||
description: paymentProof
|
description:
|
||||||
? 'Payment receipt attached — this counts as payment-verified.'
|
paymentProof || depositProof
|
||||||
|
? 'A verifiable receipt was attached — this counts toward verified trades.'
|
||||||
: 'Reputation updated for both of you.',
|
: 'Reputation updated for both of you.',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -295,6 +312,52 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
<HandoffHelper item={item} />
|
<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. */}
|
{/* Shipping logistics — self-reported, both sides. */}
|
||||||
{user && !isWTB && (item.fulfilment === 'ships' || item.fulfilment === 'both') && (
|
{user && !isWTB && (item.fulfilment === 'ships' || item.fulfilment === 'both') && (
|
||||||
<ShippingStatusCard item={item} />
|
<ShippingStatusCard item={item} />
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,13 @@ export function verifyReceiptProof(preimage?: string, paymentHash?: string): boo
|
||||||
*/
|
*/
|
||||||
const STORAGE_KEY = 'skatespots:payment-receipts-v1';
|
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> {
|
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>;
|
||||||
|
|
@ -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 {
|
try {
|
||||||
const all = loadAll();
|
const all = loadAll();
|
||||||
all[coord] = receipt;
|
all[keyFor(coord, slot)] = 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): PaymentReceipt | undefined {
|
export function getReceipt(coord: string, slot: ReceiptSlot = 'payment'): PaymentReceipt | undefined {
|
||||||
return loadAll()[coord];
|
return loadAll()[keyFor(coord, slot)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearReceipt(coord: string): void {
|
export function clearReceipt(coord: string, slot: ReceiptSlot = 'payment'): void {
|
||||||
try {
|
try {
|
||||||
const all = loadAll();
|
const all = loadAll();
|
||||||
delete all[coord];
|
delete all[keyFor(coord, slot)];
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue