feat(marketplace): mutual trade attestations + reputation (kind 31871)
Built on the Attestations NIP (31871); a listing is the Assertion Event: - Seller 'Mark as sold' now opens a dialog (buyer npub + self-reported method + optional amount) that marks the listing sold AND publishes a trade attestation - Buyer sees 'Confirm this trade' on the listing and publishes the mirror attestation -> mutual pair = a verified trade - useTradeReputation aggregates sales/purchases/verified + counterparties - Profile 'Trades' card; seller 'N verified trades' badge on listing cards - NIP.md documents kind 31871 usage + our p/method/amount extensions Payment method is self-reported metadata, never proof of payment.
This commit is contained in:
parent
78324605c1
commit
883338f060
7 changed files with 662 additions and 8 deletions
70
NIP.md
70
NIP.md
|
|
@ -3070,3 +3070,73 @@ and laptop.
|
||||||
seconds and flushes on `pagehide`/`beforeunload`, so relays are never spammed.
|
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
|
- **Anon users.** With no signer (or no NIP-44), sync is a no-op and behavior is
|
||||||
identical to the previous localStorage-only implementation.
|
identical to the previous localStorage-only implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Trade Attestations (kind 31871)
|
||||||
|
|
||||||
|
Marketplace reputation is built on the **Attestations** NIP
|
||||||
|
(<https://nostr.com/naddr1qvzqqqrcvypzp384u7n44r8rdq74988lqcmggww998jjg0rtzfd6dpufrxy9djk8qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsqrrpw36x2um5v96xjmmwwvuhdk8z>),
|
||||||
|
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", "<seller_pubkey>:trade:sold:<listing-d>"],
|
||||||
|
["a", "30402:<seller>:<listing-d>"],
|
||||||
|
["s", "valid"],
|
||||||
|
["p", "<buyer_pubkey>"],
|
||||||
|
["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:<listing-d>`, `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.
|
||||||
|
|
|
||||||
169
src/components/MarkSoldDialog.tsx
Normal file
169
src/components/MarkSoldDialog.tsx
Normal file
|
|
@ -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<TradeMethod>('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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-[#ccff00]/40 bg-black sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">Close the deal</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm text-gray-400">
|
||||||
|
Marks this listing sold and records a signed trade attestation for you and the buyer.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="buyer" className="flex items-center gap-2 text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
Buyer's npub
|
||||||
|
{buyerPubkey && <Check className="h-3.5 w-3.5 text-[#ccff00]" />}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="buyer"
|
||||||
|
value={buyer}
|
||||||
|
onChange={(e) => setBuyer(e.target.value)}
|
||||||
|
placeholder="npub1…"
|
||||||
|
className="h-11 border-gray-700 bg-gray-900 font-mono text-xs text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs uppercase tracking-wide text-gray-400">How was it paid? (self-reported)</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(Object.keys(TRADE_METHODS) as TradeMethod[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMethod(m)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-3 py-1.5 text-xs font-bold transition-colors',
|
||||||
|
method === m
|
||||||
|
? 'border-[#ccff00] bg-[#ccff00]/15 text-[#ccff00]'
|
||||||
|
: 'border-white/15 text-gray-400 hover:border-white/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{TRADE_METHODS[m]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="amount" className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
Amount in sats (optional)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="amount"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="e.g. 45000"
|
||||||
|
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || !buyerPubkey}
|
||||||
|
className="flex-1 gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||||
|
Mark sold
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,8 @@ import { useListingReports } from '@/hooks/useListingReports';
|
||||||
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||||
import { useTrustScore } from '@/hooks/useTrustScore';
|
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||||
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||||
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
|
import { useListingTrade, useTradeAttestation, useTradeReputation } from '@/hooks/useTradeAttestations';
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
|
|
@ -66,7 +68,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||||
const { markSold, relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
||||||
const author = useAuthor(item.pubkey);
|
const author = useAuthor(item.pubkey);
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
||||||
|
|
@ -88,6 +90,13 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
const [reportReason, setReportReason] = useState<string>('');
|
const [reportReason, setReportReason] = useState<string>('');
|
||||||
const [reportDetails, setReportDetails] = useState('');
|
const [reportDetails, setReportDetails] = useState('');
|
||||||
const [isReporting, setIsReporting] = useState(false);
|
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
|
// Format price display
|
||||||
const priceDisplay = item.price
|
const priceDisplay = item.price
|
||||||
|
|
@ -182,14 +191,25 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
const isOwnListing = user?.pubkey === item.pubkey;
|
const isOwnListing = user?.pubkey === item.pubkey;
|
||||||
const isSold = item.status === 'sold';
|
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 {
|
try {
|
||||||
await markSold(item.event);
|
await publishTrade({ listing: listingRef, role: 'bought', counterparty: item.pubkey });
|
||||||
toast({ title: 'Marked as sold', description: 'Your listing is now shown as sold.' });
|
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Could not update',
|
title: 'Could not confirm the trade',
|
||||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +352,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
Relist as active
|
Relist as active
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem onClick={handleMarkSold} disabled={isUpdatingStatus}>
|
<DropdownMenuItem onClick={() => setShowMarkSold(true)} disabled={isUpdatingStatus}>
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
Mark as sold
|
Mark as sold
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -369,6 +389,12 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
{categoryLabel}
|
{categoryLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{sellerRep && sellerRep.verified > 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]">
|
||||||
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
|
{sellerRep.verified} verified trade{sellerRep.verified === 1 ? '' : 's'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|
@ -461,6 +487,19 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
"coming soon" toast is a dead end — buyers contact the seller
|
"coming soon" toast is a dead end — buyers contact the seller
|
||||||
and arrange payment directly. */}
|
and arrange payment directly. */}
|
||||||
|
|
||||||
|
{/* Buyer: confirm the trade the seller recorded (builds both reps) */}
|
||||||
|
{sellerNamedMe && !iConfirmedTrade && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirmTrade}
|
||||||
|
disabled={confirmingTrade}
|
||||||
|
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||||
|
>
|
||||||
|
{confirmingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
||||||
|
Confirm this trade
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contact button */}
|
{/* Contact button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -480,6 +519,16 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Seller: close the deal (mark sold + publish trade attestation) */}
|
||||||
|
{isOwnListing && (
|
||||||
|
<MarkSoldDialog
|
||||||
|
open={showMarkSold}
|
||||||
|
onOpenChange={setShowMarkSold}
|
||||||
|
event={item.event}
|
||||||
|
listing={listingRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
|
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
|
||||||
<DeleteEntityDialog
|
<DeleteEntityDialog
|
||||||
event={item.event}
|
event={item.event}
|
||||||
|
|
|
||||||
52
src/components/TradeReputationCard.tsx
Normal file
52
src/components/TradeReputationCard.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useTradeReputation } from '@/hooks/useTradeAttestations';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Handshake } from 'lucide-react';
|
||||||
|
|
||||||
|
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/[0.03] px-2 py-3">
|
||||||
|
<p className={cn('text-xl font-black', accent ? 'text-[#ccff00]' : 'text-white')}>{value}</p>
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-gray-500">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace reputation for a profile, derived from mutually-signed trade
|
||||||
|
* attestations (kind 31871, see NIP.md → Trade Attestations).
|
||||||
|
*
|
||||||
|
* "Verified" is the strong signal: the seller AND the counterparty both signed
|
||||||
|
* an attestation for the same listing. Renders nothing when there are no trades.
|
||||||
|
*/
|
||||||
|
export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||||
|
const { data } = useTradeReputation(pubkey);
|
||||||
|
const total = (data?.sales ?? 0) + (data?.purchases ?? 0);
|
||||||
|
|
||||||
|
if (!data || total === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('bg-black/90 border-2 border-[#ff6600]/25', className)}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||||
|
<Handshake className="h-5 w-5" />
|
||||||
|
Trades
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<Stat label="Verified" value={data.verified} accent />
|
||||||
|
<Stat label="Sales" value={data.sales} />
|
||||||
|
<Stat label="Purchases" value={data.purchases} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{data.verified > 0
|
||||||
|
? `${data.verified} mutually-confirmed ${data.verified === 1 ? 'trade' : 'trades'}`
|
||||||
|
: 'Awaiting mutual confirmation'}
|
||||||
|
{data.counterparties.length > 0 &&
|
||||||
|
` · ${data.counterparties.length} counterpart${data.counterparties.length === 1 ? 'y' : 'ies'}`}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/hooks/useTradeAttestations.test.ts
Normal file
58
src/hooks/useTradeAttestations.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { parseListingCoord, isTradeAttestation, TRADE_ATTESTATION_KIND } from './useTradeAttestations';
|
||||||
|
|
||||||
|
function ev(partial: Partial<NostrEvent> & Pick<NostrEvent, 'kind' | 'pubkey' | 'id'>): NostrEvent {
|
||||||
|
return { content: '', created_at: 0, sig: '', tags: [], ...partial } as NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseListingCoord', () => {
|
||||||
|
it('parses a 30402 coordinate', () => {
|
||||||
|
expect(parseListingCoord('30402:abc123:my-listing')).toEqual({
|
||||||
|
kind: 30402,
|
||||||
|
pubkey: 'abc123',
|
||||||
|
identifier: 'my-listing',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps colons that are part of the identifier', () => {
|
||||||
|
expect(parseListingCoord('30402:abc123:slug:part2')?.identifier).toBe('slug:part2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for missing or malformed coords', () => {
|
||||||
|
expect(parseListingCoord(undefined)).toBeNull();
|
||||||
|
expect(parseListingCoord('nope')).toBeNull();
|
||||||
|
expect(parseListingCoord('30402:onlypubkey')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isTradeAttestation', () => {
|
||||||
|
it('accepts a 31871 attestation of a 30402 listing with a counterparty', () => {
|
||||||
|
const e = ev({
|
||||||
|
kind: TRADE_ATTESTATION_KIND,
|
||||||
|
pubkey: 'seller',
|
||||||
|
id: 'a1',
|
||||||
|
tags: [['a', '30402:seller:deck'], ['p', 'buyer'], ['s', 'valid']],
|
||||||
|
});
|
||||||
|
expect(isTradeAttestation(e)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an attestation without a counterparty', () => {
|
||||||
|
const e = ev({
|
||||||
|
kind: TRADE_ATTESTATION_KIND,
|
||||||
|
pubkey: 'x',
|
||||||
|
id: 'a2',
|
||||||
|
tags: [['a', '30402:x:y'], ['s', 'valid']],
|
||||||
|
});
|
||||||
|
expect(isTradeAttestation(e)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-listing assertions and other kinds', () => {
|
||||||
|
expect(
|
||||||
|
isTradeAttestation(ev({ kind: TRADE_ATTESTATION_KIND, pubkey: 'x', id: 'a3', tags: [['a', '35000:x:y'], ['p', 'z']] })),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isTradeAttestation(ev({ kind: 1, pubkey: 'x', id: 'a4', tags: [['a', '30402:x:y'], ['p', 'z']] })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
252
src/hooks/useTradeAttestations.ts
Normal file
252
src/hooks/useTradeAttestations.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade attestations (completed deals) — built on the Attestations NIP
|
||||||
|
* (kind 31871), see NIP.md → "Trade Attestations".
|
||||||
|
*
|
||||||
|
* A **listing** (kind 30402) is the *Assertion Event*. When a deal closes:
|
||||||
|
* • the seller publishes a `31871` attesting it (`s=valid`) and naming the
|
||||||
|
* buyer in a `p` tag (our documented extension);
|
||||||
|
* • the buyer may publish a mirror `31871` naming the seller.
|
||||||
|
* Two reciprocal attestations on the same listing = a **verified trade**.
|
||||||
|
*
|
||||||
|
* Payment method is self-reported metadata; the app cannot observe cash/trade
|
||||||
|
* deals and never treats these as proof of payment.
|
||||||
|
*/
|
||||||
|
export const TRADE_ATTESTATION_KIND = 31871;
|
||||||
|
|
||||||
|
export const TRADE_METHODS = {
|
||||||
|
cash: 'Cash',
|
||||||
|
lightning: 'Lightning',
|
||||||
|
trade: 'Trade',
|
||||||
|
other: 'Other',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TradeMethod = keyof typeof TRADE_METHODS;
|
||||||
|
export type TradeRole = 'sold' | 'bought';
|
||||||
|
|
||||||
|
export interface ListingCoord {
|
||||||
|
kind: number;
|
||||||
|
pubkey: string;
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an `a` tag ("30402:<pubkey>:<d>") into its parts, or null. */
|
||||||
|
export function parseListingCoord(aTag: string | undefined): ListingCoord | null {
|
||||||
|
if (!aTag) return null;
|
||||||
|
const [kindStr, pubkey, ...rest] = aTag.split(':');
|
||||||
|
const kind = Number(kindStr);
|
||||||
|
if (!Number.isFinite(kind) || !pubkey || rest.length === 0) return null;
|
||||||
|
return { kind, pubkey, identifier: rest.join(':') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this kind-31871 event a *trade* attestation? We require a 30402 assertion
|
||||||
|
* (`a` tag) plus a counterparty (`p`) — our documented marker for trades.
|
||||||
|
*/
|
||||||
|
export function isTradeAttestation(ev: NostrEvent): boolean {
|
||||||
|
if (ev.kind !== TRADE_ATTESTATION_KIND) return false;
|
||||||
|
const a = ev.tags.find(([t]) => t === 'a')?.[1];
|
||||||
|
const coord = parseListingCoord(a);
|
||||||
|
if (!coord || coord.kind !== MARKETPLACE_ITEM_KIND) return false;
|
||||||
|
return ev.tags.some(([t]) => t === 'p');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a trade attestation (kind 31871, addressable).
|
||||||
|
*
|
||||||
|
* `d` is derived from the listing + role so re-publishing the same attestation
|
||||||
|
* replaces it (e.g. to set `s=revoked`), per the NIP's lifecycle.
|
||||||
|
*/
|
||||||
|
export function useTradeAttestation() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||||||
|
|
||||||
|
const publishTrade = async ({
|
||||||
|
listing,
|
||||||
|
role,
|
||||||
|
counterparty,
|
||||||
|
method,
|
||||||
|
amountSats,
|
||||||
|
state = 'valid',
|
||||||
|
note,
|
||||||
|
}: {
|
||||||
|
/** The listing being attested. */
|
||||||
|
listing: { pubkey: string; identifier: string };
|
||||||
|
role: TradeRole;
|
||||||
|
/** The other party's pubkey. */
|
||||||
|
counterparty: string;
|
||||||
|
method?: TradeMethod;
|
||||||
|
amountSats?: number;
|
||||||
|
state?: 'verifying' | 'valid' | 'revoked';
|
||||||
|
note?: string;
|
||||||
|
}) => {
|
||||||
|
if (!user) throw new Error('You must be logged in.');
|
||||||
|
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
|
||||||
|
|
||||||
|
const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`;
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['d', `${user.pubkey}:trade:${role}:${listing.identifier}`],
|
||||||
|
['a', coord],
|
||||||
|
['s', state],
|
||||||
|
['p', counterparty],
|
||||||
|
['alt', role === 'sold' ? 'SkateSpots: sale completed' : 'SkateSpots: purchase confirmed'],
|
||||||
|
];
|
||||||
|
if (method) tags.push(['method', method]);
|
||||||
|
if (typeof amountSats === 'number' && amountSats > 0) {
|
||||||
|
tags.push(['amount', String(Math.round(amountSats)), 'SATS']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = user.pubkey.slice(0, 8);
|
||||||
|
const content =
|
||||||
|
note?.trim() ||
|
||||||
|
(role === 'sold' ? `Sold by ${name}.` : `Bought from ${name}.`);
|
||||||
|
|
||||||
|
await publish({ kind: TRADE_ATTESTATION_KIND, content, tags });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { publishTrade, isPending };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeReputation {
|
||||||
|
/** Listings this pubkey sold (they attested as the listing author). */
|
||||||
|
sales: number;
|
||||||
|
/** Listings they attested as the non-owner party. */
|
||||||
|
purchases: number;
|
||||||
|
/** Deals where both sides attested the same listing. */
|
||||||
|
verified: number;
|
||||||
|
/** Distinct counterparties involved. */
|
||||||
|
counterparties: string[];
|
||||||
|
/** Raw trade attestations this pubkey is involved in. */
|
||||||
|
attestations: NostrEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade reputation for a pubkey: seller/buyer positions derived from kind-31871
|
||||||
|
* attestations, plus a "verified" count where seller and counterparty both
|
||||||
|
* attested the same listing (the strong, mutually-confirmed signal).
|
||||||
|
*
|
||||||
|
* NOTE: consumers should trust-weight counterparties with the existing WoT
|
||||||
|
* score so rings of fresh accounts can't inflate reputation.
|
||||||
|
*/
|
||||||
|
export function useTradeReputation(pubkey: string | undefined) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
return useQuery<TradeReputation>({
|
||||||
|
queryKey: ['trade-reputation', pubkey],
|
||||||
|
enabled: !!pubkey,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
queryFn: async (c): Promise<TradeReputation> => {
|
||||||
|
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, counterparties: [], attestations: [] };
|
||||||
|
if (!pubkey) return empty;
|
||||||
|
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
||||||
|
const events = await nostr.query(
|
||||||
|
[
|
||||||
|
{ kinds: [TRADE_ATTESTATION_KIND], authors: [pubkey], limit: 200 },
|
||||||
|
{ kinds: [TRADE_ATTESTATION_KIND], '#p': [pubkey], limit: 200 },
|
||||||
|
],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
const unique = Array.from(new Map(events.map((e) => [e.id, e])).values()).filter(
|
||||||
|
(e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by the attested listing coordinate.
|
||||||
|
const byListing = new Map<string, NostrEvent[]>();
|
||||||
|
for (const ev of unique) {
|
||||||
|
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
|
||||||
|
if (!coord) continue;
|
||||||
|
const key = `${coord.kind}:${coord.pubkey}:${coord.identifier}`;
|
||||||
|
const list = byListing.get(key) ?? [];
|
||||||
|
list.push(ev);
|
||||||
|
byListing.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counterparties = new Set<string>();
|
||||||
|
let sales = 0;
|
||||||
|
let purchases = 0;
|
||||||
|
let verified = 0;
|
||||||
|
|
||||||
|
for (const [key, attestations] of byListing) {
|
||||||
|
const coord = parseListingCoord(key);
|
||||||
|
if (!coord) continue;
|
||||||
|
const seller = coord.pubkey;
|
||||||
|
const attestors = new Set(attestations.map((e) => e.pubkey));
|
||||||
|
const isVerified = attestors.has(seller) && attestors.size >= 2;
|
||||||
|
const involved = attestors.has(pubkey);
|
||||||
|
|
||||||
|
if (involved) {
|
||||||
|
if (pubkey === seller) sales++;
|
||||||
|
else purchases++;
|
||||||
|
if (isVerified) verified++;
|
||||||
|
|
||||||
|
// Record counterparties (everyone else who attested this listing).
|
||||||
|
for (const a of attestors) if (a !== pubkey) counterparties.add(a);
|
||||||
|
for (const ev of attestations) {
|
||||||
|
for (const [, value] of ev.tags.filter(([t]) => t === 'p')) {
|
||||||
|
if (value !== pubkey) counterparties.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sales,
|
||||||
|
purchases,
|
||||||
|
verified,
|
||||||
|
counterparties: [...counterparties],
|
||||||
|
attestations: unique,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade attestations for one listing, from the viewer's perspective:
|
||||||
|
* `iConfirmed` (they attested it) and `counterparty` (who else has).
|
||||||
|
*/
|
||||||
|
export function useListingTrade(
|
||||||
|
listing: { pubkey: string; identifier: string } | undefined,
|
||||||
|
viewerPubkey: string | undefined,
|
||||||
|
) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['listing-trade', coord, viewerPubkey],
|
||||||
|
enabled: !!coord,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
if (!coord) return { attestations: [] as NostrEvent[], iConfirmed: false, counterparties: [] as string[] };
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [TRADE_ATTESTATION_KIND], '#a': [coord], limit: 50 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const attestations = events.filter(
|
||||||
|
(e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked',
|
||||||
|
);
|
||||||
|
const authorSet = new Set(attestations.map((e) => e.pubkey));
|
||||||
|
const counterparties = new Set<string>();
|
||||||
|
for (const ev of attestations) {
|
||||||
|
for (const [, value] of ev.tags.filter(([t]) => t === 'p')) {
|
||||||
|
if (value !== viewerPubkey) counterparties.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const a of authorSet) if (a !== viewerPubkey) counterparties.add(a);
|
||||||
|
return {
|
||||||
|
attestations,
|
||||||
|
iConfirmed: !!viewerPubkey && authorSet.has(viewerPubkey),
|
||||||
|
counterparties: [...counterparties],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { ZapBadgesCard } from '@/components/ZapBadgesCard';
|
import { ZapBadgesCard } from '@/components/ZapBadgesCard';
|
||||||
|
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -712,6 +713,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
{/* Zap achievements (only renders when this profile has sent zaps) */}
|
{/* Zap achievements (only renders when this profile has sent zaps) */}
|
||||||
<ZapBadgesCard pubkey={pubkey} />
|
<ZapBadgesCard pubkey={pubkey} />
|
||||||
|
|
||||||
|
{/* Marketplace trade reputation (only renders with trades) */}
|
||||||
|
<TradeReputationCard pubkey={pubkey} />
|
||||||
|
|
||||||
{/* Quick Info Card */}
|
{/* Quick Info Card */}
|
||||||
<Card className="bg-black/90 border-2 border-gray-800">
|
<Card className="bg-black/90 border-2 border-gray-800">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue