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:
SkateSpots 2026-09-26 20:17:38 +07:00
parent 78324605c1
commit 883338f060
7 changed files with 662 additions and 8 deletions

70
NIP.md
View file

@ -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
(<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.

View 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&apos;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>
);
}

View file

@ -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<string>('');
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
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={handleMarkSold} disabled={isUpdatingStatus}>
<DropdownMenuItem onClick={() => setShowMarkSold(true)} disabled={isUpdatingStatus}>
<CheckCircle className="h-4 w-4 mr-2" />
Mark as sold
</DropdownMenuItem>
@ -369,6 +389,12 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{categoryLabel}
</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>
{/* 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 && (
<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 */}
<Button
variant="outline"
@ -480,6 +519,16 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
</div>
</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) */}
<DeleteEntityDialog
event={item.event}

View 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>
);
}

View 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);
});
});

View 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],
};
},
});
}

View file

@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { ZapBadgesCard } from '@/components/ZapBadgesCard';
import { TradeReputationCard } from '@/components/TradeReputationCard';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -712,6 +713,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* Zap achievements (only renders when this profile has sent zaps) */}
<ZapBadgesCard pubkey={pubkey} />
{/* Marketplace trade reputation (only renders with trades) */}
<TradeReputationCard pubkey={pubkey} />
{/* Quick Info Card */}
<Card className="bg-black/90 border-2 border-gray-800">
<CardHeader className="pb-3">