diff --git a/NIP.md b/NIP.md index cb5eb45..ee2ac35 100644 --- a/NIP.md +++ b/NIP.md @@ -3070,3 +3070,73 @@ and laptop. seconds and flushes on `pagehide`/`beforeunload`, so relays are never spammed. - **Anon users.** With no signer (or no NIP-44), sync is a no-op and behavior is identical to the previous localStorage-only implementation. + +--- + +# Trade Attestations (kind 31871) + +Marketplace reputation is built on the **Attestations** NIP +(), +authored by `npub1cn670f663n3ks02jnnlsvd5y88zjnefy8343ykaxs7y3nzzketrsrjwt8a`. + +A **listing** (kind `30402`) is the *Assertion Event*. When a deal closes, both +parties can publish a `31871` Attestation about it. Two reciprocal attestations +on the same listing form a **verified trade**. + +## Seller — sale completed + +```json +{ + "kind": 31871, + "tags": [ + ["d", ":trade:sold:"], + ["a", "30402::"], + ["s", "valid"], + ["p", ""], + ["method", "cash|lightning|trade|other"], + ["amount", "45000", "SATS"], + ["alt", "SkateSpots: sale completed"] + ], + "content": "Sold to @buyer. Paid in cash." +} +``` + +## Buyer — purchase confirmed + +Same shape, `d` ends `:trade:bought:`, `p` names the **seller**, and +`content` is a human description. + +## Extension tags (SkateSpots profile) + +The base NIP has no counterparty field for this use case, so we add: + +| Tag | Meaning | +| --- | --- | +| `p` | The **other party** in the trade (required by us, marks a trade attestation) | +| `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` | +| `amount` | Optional self-reported amount, `["amount","45000","SATS"]` | + +`method`/`amount` are **self-reported metadata, never proof of payment** — the app +cannot observe off-app (cash/trade) settlement. + +## States & revocation + +`["s", "verifying" | "valid" | "revoked"]` follows the NIP's lifecycle. Because +`31871` is addressable, re-publishing the same `d` with `["s","revoked"]` +retracts an attestation (e.g. a disputed or cancelled deal). + +## Reputation semantics + +- **sales** — listings where the attestor is the listing author +- **purchases** — listings where the attestor is not the author +- **verified** — seller **and** counterparty both attested the same listing +- Consumers **trust-weight counterparties** with the existing web-of-trust score + (as with Scene vouches) so fresh-account rings cannot inflate reputation. + +## Future (from the NIP) + +- `31872` **Attestation Request** — a buyer can request the seller's attestation; + it carries a `cashu_token` lockable to spending conditions (HTLC/P2PK) → a + Cashu-based escrow / release-on-attestation flow. +- `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader + endorsements and attestor discovery. diff --git a/src/components/MarkSoldDialog.tsx b/src/components/MarkSoldDialog.tsx new file mode 100644 index 0000000..20f20ca --- /dev/null +++ b/src/components/MarkSoldDialog.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import { nip19 } from 'nostr-tools'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useTradeAttestation, TRADE_METHODS, type TradeMethod } from '@/hooks/useTradeAttestations'; +import { useMarkListingSold } from '@/hooks/useMarkListingSold'; +import { useToast } from '@/hooks/useToast'; +import { cn } from '@/lib/utils'; +import { Check, Loader2 } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +function resolvePubkey(input: string): string | null { + const t = input.trim(); + if (/^[a-f0-9]{64}$/i.test(t)) return t.toLowerCase(); + if (t.startsWith('npub1')) { + try { + const decoded = nip19.decode(t); + return decoded.type === 'npub' ? decoded.data : null; + } catch { + return null; + } + } + return null; +} + +/** + * Seller's "close the deal" flow: mark the listing sold AND publish a trade + * attestation (kind 31871) naming the buyer, so both sides can build + * reputation. Payment method is self-reported — the app can't see cash/trade + * deals and never treats it as proof of payment. + */ +export function MarkSoldDialog({ + open, + onOpenChange, + event, + listing, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + event: NostrEvent; + listing: { pubkey: string; identifier: string }; +}) { + const { publishTrade, isPending: publishing } = useTradeAttestation(); + const { markSold, isPending: marking } = useMarkListingSold(); + const { toast } = useToast(); + + const [buyer, setBuyer] = useState(''); + const [method, setMethod] = useState('cash'); + const [amount, setAmount] = useState(''); + + const buyerPubkey = resolvePubkey(buyer); + const busy = publishing || marking; + + const submit = async () => { + if (!buyerPubkey) { + toast({ title: 'Enter the buyer’s npub', variant: 'destructive' }); + return; + } + try { + await markSold(event); + await publishTrade({ + listing, + role: 'sold', + counterparty: buyerPubkey, + method, + amountSats: amount.trim() ? Number(amount) : undefined, + }); + toast({ + title: 'Deal closed 🎉', + description: 'Listing marked sold and the trade was published for reputation.', + }); + onOpenChange(false); + setBuyer(''); + setAmount(''); + } catch (e) { + toast({ + title: 'Could not close the deal', + description: e instanceof Error ? e.message : 'Something went wrong.', + variant: 'destructive', + }); + } + }; + + return ( + + + + Close the deal + + Marks this listing sold and records a signed trade attestation for you and the buyer. + + + +
+
+ + setBuyer(e.target.value)} + placeholder="npub1…" + className="h-11 border-gray-700 bg-gray-900 font-mono text-xs text-white" + /> +
+ +
+ +
+ {(Object.keys(TRADE_METHODS) as TradeMethod[]).map((m) => ( + + ))} +
+
+ +
+ + setAmount(e.target.value)} + inputMode="numeric" + placeholder="e.g. 45000" + className="h-11 border-gray-700 bg-gray-900 text-white" + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx index a7f88f2..62433bb 100644 --- a/src/components/MarketplaceItemCard.tsx +++ b/src/components/MarketplaceItemCard.tsx @@ -32,6 +32,8 @@ import { useListingReports } from '@/hooks/useListingReports'; import { useSellerInfo } from '@/hooks/useSellerInfo'; import { useTrustScore } from '@/hooks/useTrustScore'; import { useMarkListingSold } from '@/hooks/useMarkListingSold'; +import { MarkSoldDialog } from '@/components/MarkSoldDialog'; +import { useListingTrade, useTradeAttestation, useTradeReputation } from '@/hooks/useTradeAttestations'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { genUserName } from '@/lib/genUserName'; import { TrustBadge } from '@/components/TrustBadge'; @@ -66,7 +68,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa const { toast } = useToast(); const navigate = useNavigate(); const { mutateAsync: publishEvent } = useNostrPublish(); - const { markSold, relist, isPending: isUpdatingStatus } = useMarkListingSold(); + const { relist, isPending: isUpdatingStatus } = useMarkListingSold(); const author = useAuthor(item.pubkey); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(item.pubkey); @@ -88,6 +90,13 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa const [reportReason, setReportReason] = useState(''); const [reportDetails, setReportDetails] = useState(''); const [isReporting, setIsReporting] = useState(false); + const [showMarkSold, setShowMarkSold] = useState(false); + + // Trade attestations (Attestations NIP, kind 31871) — see useTradeAttestations. + const listingRef = { pubkey: item.pubkey, identifier: item.identifier }; + const { data: trade } = useListingTrade(listingRef, user?.pubkey); + const { data: sellerRep } = useTradeReputation(item.pubkey); + const { publishTrade, isPending: confirmingTrade } = useTradeAttestation(); // Format price display const priceDisplay = item.price @@ -182,14 +191,25 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa const isOwnListing = user?.pubkey === item.pubkey; const isSold = item.status === 'sold'; - const handleMarkSold = async () => { + // Was the viewer named as the buyer by the seller? Then they can confirm. + const sellerNamedMe = + !!user && + !isOwnListing && + !!trade && + trade.attestations.some( + (ev) => ev.pubkey === item.pubkey && ev.tags.some(([t, v]) => t === 'p' && v === user.pubkey), + ); + const iConfirmedTrade = !!trade?.iConfirmed; + + const handleConfirmTrade = async () => { + if (!user) return; try { - await markSold(item.event); - toast({ title: 'Marked as sold', description: 'Your listing is now shown as sold.' }); - } catch (error) { + await publishTrade({ listing: listingRef, role: 'bought', counterparty: item.pubkey }); + toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' }); + } catch (e) { toast({ - title: 'Could not update', - description: error instanceof Error ? error.message : 'Something went wrong', + title: 'Could not confirm the trade', + description: e instanceof Error ? e.message : 'Something went wrong.', variant: 'destructive', }); } @@ -332,7 +352,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa Relist as active ) : ( - + setShowMarkSold(true)} disabled={isUpdatingStatus}> Mark as sold @@ -369,6 +389,12 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa {categoryLabel} )} + {sellerRep && sellerRep.verified > 0 && ( + + + {sellerRep.verified} verified trade{sellerRep.verified === 1 ? '' : 's'} + + )} {/* Description */} @@ -461,6 +487,19 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa "coming soon" toast is a dead end — buyers contact the seller and arrange payment directly. */} + {/* Buyer: confirm the trade the seller recorded (builds both reps) */} + {sellerNamedMe && !iConfirmedTrade && ( + + )} + {/* Contact button */}