import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import type { NostrEvent } from '@nostrify/nostrify'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { verifyZapReceipt } from '@/lib/zapReceipt'; import { verifyReceiptProof } from '@/lib/receipts'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { MARKETPLACE_ITEM_KIND } from '@/lib/types'; import { onlyVerified } from '@/lib/verifyEvents'; /** Keep only the newest event per (author, kind, d) — addressable semantics. */ function newestPerCoordinate(events: NostrEvent[]): NostrEvent[] { const byKey = new Map(); for (const ev of events) { const d = ev.tags.find(([t]) => t === 'd')?.[1] ?? ''; const key = `${ev.kind}:${ev.pubkey}:${d}`; const current = byKey.get(key); if (!current || ev.created_at > current.created_at) byKey.set(key, ev); } return [...byKey.values()]; } /** * 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, paymentProof, depositProof, }: { /** 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; /** A verifiable Lightning receipt: sha256(preimage) === paymentHash. */ paymentProof?: { preimage: string; paymentHash: string }; /** A refundable swap-deposit receipt (barter) — verified the same way. */ depositProof?: { preimage: string; paymentHash: 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]); // Verifiable receipts (anyone can check sha256(preimage) === payment_hash). if (paymentProof?.preimage && paymentProof.paymentHash) { tags.push(['proof', 'lnpreimage', paymentProof.preimage, paymentProof.paymentHash]); } if (depositProof?.preimage && depositProof.paymentHash) { tags.push(['proof', 'deposit', depositProof.preimage, depositProof.paymentHash]); } 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; /** Mutually confirmed deals with an attestation in the last 90 days. */ recentVerified: number; /** * Mutually-confirmed trades that ALSO carry a `proof` tag (a payment claim). * ⚠️ The `proof` is self-reported and the app does NOT verify it — it is a * stronger *claim*, never evidence of payment. Show it as "with payment proof", * not "verified". */ withPaymentProof: number; /** * Trades whose `proof` is a **verified** zap receipt (kind 9735 — signature, * recipient and amount all checked). The only client-verifiable tier today. */ paymentVerified: 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; recentCount: number; lastAt: 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, recentVerified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] }; if (!pubkey) return empty; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]); const events = onlyVerified(await nostr.query( [ { kinds: [TRADE_ATTESTATION_KIND], authors: [pubkey], limit: 200 }, { kinds: [TRADE_ATTESTATION_KIND], '#p': [pubkey], limit: 200 }, ], { signal }, )); // Newest per coordinate wins, so a newer `s=revoked` supersedes an older // `valid` copy that a stale relay still serves. const unique = newestPerCoordinate(events).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(); const verifiedRecentWith = new Map(); const verifiedLastAt = new Map(); let sales = 0; let purchases = 0; let verified = 0; let recentVerified = 0; let withPaymentProof = 0; const recentCutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60; 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++; const latestAt = Math.max(...attestations.map((e) => e.created_at)); const isRecent = latestAt >= recentCutoff; if (isRecent) recentVerified++; // 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); if (isRecent) verifiedRecentWith.set(a, (verifiedRecentWith.get(a) ?? 0) + 1); verifiedLastAt.set(a, Math.max(verifiedLastAt.get(a) ?? 0, latestAt)); } } // A `proof` tag is a payment CLAIM, not verification — count it on its // own tier so it is never confused with a confirmed trade. if (attestations.some((e) => e.tags.some(([t]) => t === 'proof'))) { withPaymentProof++; } } // 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); } } } } // Payment-VERIFIED tier — count only proofs we can actually check: // • a Lightning preimage (`sha256(preimage) === payment_hash`), and // • a NIP-57 zap receipt (signature + recipient + amount, fetched below). const zapProofs = new Map(); // receiptId → expected seller const usedPreimages = new Set(); // one payment counts once let paymentVerified = 0; for (const ev of unique) { const proof = ev.tags.find(([t]) => t === 'proof'); if (!proof) continue; if ((proof[1] === 'lnpreimage' || proof[1] === 'deposit') && proof[2] && proof[3]) { // SECURITY: a real preimage reused across many attestations must not // inflate the "payment-verified" tier — count each preimage once. const preimage = proof[2]; if (!usedPreimages.has(preimage) && verifyReceiptProof(preimage, proof[3])) { usedPreimages.add(preimage); paymentVerified += 1; } } else if (proof[1] === 'zap' && proof[2]) { const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]); if (coord) zapProofs.set(proof[2], coord.pubkey); } } if (zapProofs.size > 0) { const receipts = await nostr.query( [{ kinds: [9735], ids: [...zapProofs.keys()], limit: 100 }], { signal }, ); for (const r of receipts) { const expected = zapProofs.get(r.id); if (expected && verifyZapReceipt(r, expected).valid) paymentVerified += 1; } } return { sales, purchases, verified, recentVerified, withPaymentProof, paymentVerified, verifiedCounterparties: verifiedWith.size, verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count, recentCount: verifiedRecentWith.get(pubkey) ?? 0, lastAt: verifiedLastAt.get(pubkey) ?? 0, })), 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 }).then(onlyVerified), nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified), ]); 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 = onlyVerified(await nostr.query( [{ kinds: [TRADE_ATTESTATION_KIND], '#a': [coord], limit: 50 }], { signal }, )); const attestations = newestPerCoordinate(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 }).then(onlyVerified), nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified), ]); // 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), }; }, }); }