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; /** * Attestation Request (kind 31872) — the request side of the flow. A buyer * whose deal wasn't recorded by the seller can ask them to publish a `31871`; * the seller's client surfaces it and fulfils it in one tap. * See NIP.md → "Trade Attestations". */ export const TRADE_REQUEST_KIND = 31872; 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::") 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'); } /** * Is this kind-31872 event a *trade* request? Same marker as attestations: a * 30402 assertion (`a`) plus a requested attestor (`p`). */ export function isTradeRequest(ev: NostrEvent): boolean { if (ev.kind !== TRADE_REQUEST_KIND) return false; const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]); 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, rating, requestCoord, }: { /** 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; /** Trade rating, 1–10 (skateboard decks). Optional. */ rating?: number; /** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */ requestCoord?: 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']); } if (typeof rating === 'number' && rating >= 1 && rating <= 10) { tags.push(['rating', String(Math.round(rating))]); } if (requestCoord) tags.push(['request', requestCoord]); 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 with at least one mutually-confirmed trade. * * This is the anti-collusion headline: two accounts trading only with each * other can inflate `verified` without bound, but never `verifiedCounterparties` * beyond 1. UI should lead with this instead of the raw count. */ verifiedCounterparties: number; /** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */ verifiedWith: { pubkey: string; count: 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({ queryKey: ['trade-reputation', pubkey], enabled: !!pubkey, staleTime: 5 * 60 * 1000, queryFn: async (c): Promise => { const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, verifiedCounterparties: 0, verifiedWith: [], 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(); 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(); const verifiedWith = new Map(); 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++; // Attribute the verified trade to the other attestor(s). for (const a of attestors) { if (a !== pubkey) verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1); } } // 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, verifiedCounterparties: verifiedWith.size, verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })), counterparties: [...counterparties], attestations: unique, }; }, }); } /** * Trades where someone named the viewer as counterparty and the viewer has NOT * yet published their own attestation — used to prompt "confirm this trade". */ export function usePendingTradeConfirmations(viewerPubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ queryKey: ['pending-trade-confirmations', viewerPubkey], enabled: !!viewerPubkey, staleTime: 60 * 1000, queryFn: async (c) => { type Pending = { coord: string; listing: { pubkey: string; identifier: string }; seller: string; /** When the counterparty attested (drives the notification ordering). */ createdAt: number; /** The attestation event id — a stable notification key. */ eventId: string; }; if (!viewerPubkey) return [] as Pending[]; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); const [naming, mine] = await Promise.all([ nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }), nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }), ]); const myCoords = new Set( mine.filter(isTradeAttestation).map((e) => e.tags.find(([t]) => t === 'a')?.[1]).filter(Boolean), ); const pending: Pending[] = []; const seen = new Set(); for (const ev of naming.filter(isTradeAttestation)) { if (ev.pubkey === viewerPubkey) continue; const coord = ev.tags.find(([t]) => t === 'a')?.[1]; const parsed = parseListingCoord(coord); // The attestor must be the listing author (i.e. the seller's record). if (!coord || !parsed || parsed.pubkey !== ev.pubkey) continue; if (myCoords.has(coord) || seen.has(coord)) continue; seen.add(coord); pending.push({ coord, listing: { pubkey: parsed.pubkey, identifier: parsed.identifier }, seller: ev.pubkey, createdAt: ev.created_at, eventId: ev.id, }); } return pending; }, }); } /** * 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(); 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], }; }, }); } /** * Publish a trade confirmation request (kind 31872, addressable). * * A buyer who completed a deal the seller hasn't recorded asks the seller (the * listing author) to publish a `31871`. `d` is derived from the listing + the * viewer, so re-requesting replaces the previous request. */ export function useTradeAttestationRequest() { const { user } = useCurrentUser(); const { mutateAsync: publish, isPending } = useNostrPublish(); const publishRequest = async ({ listing, attestor, note, cashuToken, }: { /** The listing the deal was for. */ listing: { pubkey: string; identifier: string }; /** The party asked to attest — normally the seller (listing author). */ attestor: string; note?: string; /** * Optional escrowed Cashu token (spec `cashu_token`). Only relevant when * you created and locked one yourself; a plain token is not escrow. */ cashuToken?: string; }) => { if (!user) throw new Error('You must be logged in.'); if (attestor === 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-request:${listing.identifier}`], ['a', coord], ['p', attestor], ['alt', 'SkateSpots: trade confirmation requested'], ]; if (cashuToken?.trim()) tags.push(['cashu_token', cashuToken.trim()]); await publish({ kind: TRADE_REQUEST_KIND, content: note?.trim() || 'Please confirm our trade so it counts for reputation.', tags, }); }; return { publishRequest, isPending }; } export interface IncomingTradeRequest { coord: string; listing: { pubkey: string; identifier: string }; requestor: string; note: string; event: NostrEvent; } /** * Requests naming the viewer as the attestor (i.e. they are the seller) that * they have not yet fulfilled. Drives the "confirm this sale" prompt. */ export function useIncomingTradeRequests(viewerPubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ queryKey: ['incoming-trade-requests', viewerPubkey], enabled: !!viewerPubkey, staleTime: 60 * 1000, queryFn: async (c) => { if (!viewerPubkey) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); const [requests, mine] = await Promise.all([ nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }), nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }), ]); // Requests the viewer already fulfilled (they attested that listing). const fulfilled = new Set( mine.filter(isTradeAttestation).map((e) => e.tags.find(([t]) => t === 'a')?.[1]).filter(Boolean), ); const out: IncomingTradeRequest[] = []; const seen = new Set(); for (const ev of requests.filter(isTradeRequest)) { if (ev.pubkey === viewerPubkey) continue; const coord = ev.tags.find(([t]) => t === 'a')?.[1]; const parsed = parseListingCoord(coord); // Only the listing author (seller) can attest the sale. if (!coord || !parsed || parsed.pubkey !== viewerPubkey) continue; if (fulfilled.has(coord) || seen.has(coord)) continue; seen.add(coord); out.push({ coord, listing: { pubkey: parsed.pubkey, identifier: parsed.identifier }, requestor: ev.pubkey, note: ev.content, event: ev, }); } return out; }, }); } /** * I added a new trade request for a listing. Replaces any previous one (same `d`). * * For a plain token this is just "a token you happen to include". Real escrow * needs the token locked to spending conditions (P2PK/HTLC) plus a release * witness — see NIP.md → Trade Attestations → Escrow (not yet implemented). */ export function useListingTradeRequest( 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-request', coord, viewerPubkey], enabled: !!coord, staleTime: 60 * 1000, queryFn: async (c) => { if (!coord) return { requests: [] as NostrEvent[], iRequested: false }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); const events = ( await nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#a': [coord], limit: 50 }], { signal }) ).filter(isTradeRequest); return { requests: events, iRequested: !!viewerPubkey && events.some((e) => e.pubkey === viewerPubkey), }; }, }); }