import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { MARKETPLACE_ITEM_KIND } from '@/lib/types'; /** * Marketplace offers — a structured negotiation over a listing, documented in * `NIP.md`. * * Kind `30403` (addressable), one event per message: * a = the listing (`30402::`) * p = the OTHER party * e = the message this one answers (a counter / an accept / a decline) * role = 'offer' (sent by the buyer) | 'response' (sent by the seller) * status = open | accepted | declined | withdrawn * amount = SATS * Every message uses a UNIQUE `d`, so the whole back-and-forth is a durable * thread (both sides can counter as many times as they like). The current state * of a negotiation is simply its newest message. */ export const OFFER_KIND = 30403; export type OfferStatus = 'open' | 'accepted' | 'declined' | 'withdrawn'; export type OfferRole = 'offer' | 'response'; /** * What kind of payment pointer a seller attaches when accepting an offer. * - `lninvoice` BOLT11 — amount-bound; the exact request * - `lnaddress` Lightning address — resolved to an invoice at pay time * - `creq` NUT-18 Cashu request — recognized, not payable yet * - `clink` CLINK `noffer1…` — recognized, not payable yet */ export type PaymentKind = 'lninvoice' | 'lnaddress' | 'creq' | 'clink'; export interface MarketplaceOffer { id: string; role: OfferRole; /** Author of this message (buyer for `offer`, seller for `response`). */ author: string; buyer: string; seller: string; listingPubkey: string; listingIdentifier: string; listingCoord: string; amount?: number; currency?: string; /** What the sender is offering in kind — how a swap/barter is expressed. */ offerItem?: string; /** * Payment pointer the seller attaches when accepting — where the buyer sends * sats. Carried on the accepted message so it rides the SAME event and * notification path as the offer (no DM, no decryption needed to surface it). */ payment?: string; paymentKind?: PaymentKind; /** LN invoice payment hash, when `paymentKind === 'lninvoice'`. */ paymentHash?: string; status: OfferStatus; message: string; /** The message this one answers (`e`), when it's a counter/accept/decline. */ replyTo?: string; createdAt: number; event: NostrEvent; } function tag(event: NostrEvent, name: string): string | undefined { return event.tags.find(([n]) => n === name)?.[1]; } export function parseListingCoord(aTag: string | undefined): { pubkey: string; identifier: string } | null { if (!aTag) return null; const [kindStr, pubkey, ...rest] = aTag.split(':'); if (Number(kindStr) !== MARKETPLACE_ITEM_KIND || !pubkey || rest.length === 0) return null; return { pubkey, identifier: rest.join(':') }; } export function parseOffer(event: NostrEvent): MarketplaceOffer | null { if (event.kind !== OFFER_KIND) return null; const d = tag(event, 'd'); const a = tag(event, 'a'); const coord = parseListingCoord(a); if (!d || !a || !coord) return null; const counterparty = tag(event, 'p'); // SECURITY: the seller is ALWAYS the listing author. Never trust a // self-declared `role` tag (or a `p` tag) to decide who the seller is — a // non-owner could otherwise post `role=response` with a `payment` pointer and // appear as the seller, tricking the buyer into paying them. const seller = coord.pubkey; const isSeller = event.pubkey.toLowerCase() === coord.pubkey.toLowerCase(); const role: OfferRole = isSeller ? 'response' : 'offer'; const amountTag = event.tags.find(([n]) => n === 'amount'); const status = (tag(event, 'status') as OfferStatus) ?? 'open'; // `["payment", "", ""]` — the seller's pointer, attached on accept. const paymentTag = event.tags.find(([n]) => n === 'payment'); const buyer = isSeller ? (counterparty ?? '') : event.pubkey; return { id: event.id, role, author: event.pubkey, buyer, seller, listingPubkey: coord.pubkey, listingIdentifier: coord.identifier, listingCoord: a, amount: amountTag?.[1] ? Number(amountTag[1]) : undefined, currency: amountTag?.[2], offerItem: tag(event, 'item'), payment: paymentTag?.[1], paymentKind: paymentTag?.[2] as PaymentKind | undefined, paymentHash: tag(event, 'payment_hash'), status, message: event.content, replyTo: tag(event, 'e'), createdAt: event.created_at, event, }; } /** Newest event per addressable slot (`d`). */ function dedupeNewest(events: NostrEvent[]): NostrEvent[] { const byD = new Map(); for (const ev of [...events].sort((a, b) => b.created_at - a.created_at)) { const d = tag(ev, 'd') ?? ev.id; if (!byD.has(d)) byD.set(d, ev); } return [...byD.values()]; } /** One negotiation between a buyer and the seller, oldest message first. */ export interface OfferThread { buyer: string; seller: string; listingCoord: string; messages: MarketplaceOffer[]; /** The message that currently represents the thread's state. */ latest: MarketplaceOffer; /** Whose turn it is to answer (null when accepted/declined/withdrawn). */ awaiting: string | null; } function toThreads(messages: MarketplaceOffer[]): OfferThread[] { const byPair = new Map(); for (const m of messages) { if (!m.buyer || !m.seller) continue; const key = `${m.listingCoord}|${m.buyer}`; const list = byPair.get(key) ?? []; list.push(m); byPair.set(key, list); } const threads: OfferThread[] = []; for (const list of byPair.values()) { const sorted = [...list].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); const latest = sorted[sorted.length - 1]; // `open` means the other party must answer; any terminal status ends it. const awaiting = latest.status === 'open' ? latest.author === latest.buyer ? latest.seller : latest.buyer : null; threads.push({ buyer: latest.buyer, seller: latest.seller, listingCoord: latest.listingCoord, messages: sorted, latest, awaiting, }); } return threads.sort((a, b) => b.latest.createdAt - a.latest.createdAt); } /** Every negotiation thread on a listing. */ export function useListingOffers(listing?: { pubkey: string; identifier: string }) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined; return useQuery<{ threads: OfferThread[] }>({ queryKey: ['listing-offers', coord, getQueryKeySuffix()], enabled: !!coord, staleTime: 60 * 1000, queryFn: async () => { if (!coord) return { threads: [] }; const events = await querySkateContent( [{ kinds: [OFFER_KIND], '#a': [coord], limit: 200 }], { signal: AbortSignal.timeout(8000), requireAny: true }, ); const messages = dedupeNewest(events) .map(parseOffer) .filter((o): o is MarketplaceOffer => !!o); return { threads: toThreads(messages) }; }, }); } /** Negotiations where the NEWEST message was authored by the other party — * i.e. everything addressed to `pubkey` that they haven't answered yet, * including terminal outcomes (accepted / declined / withdrawn). */ export function useOfferThreadsAddressedToMe(pubkey: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['offer-threads-to-me', pubkey, getQueryKeySuffix()], enabled: !!pubkey, staleTime: 60 * 1000, queryFn: async () => { if (!pubkey) return []; const events = await querySkateContent( [{ kinds: [OFFER_KIND], '#p': [pubkey], limit: 200 }], { signal: AbortSignal.timeout(8000) }, ); const messages = dedupeNewest(events) .map(parseOffer) .filter((o): o is MarketplaceOffer => !!o); return toThreads(messages).filter((t) => t.latest.author !== pubkey); }, }); } /** Negotiations where it's `pubkey`'s turn to answer (across every listing). */ export function useIncomingOffers(pubkey: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['incoming-offers', pubkey, getQueryKeySuffix()], enabled: !!pubkey, staleTime: 60 * 1000, queryFn: async () => { if (!pubkey) return []; const events = await querySkateContent( [{ kinds: [OFFER_KIND], '#p': [pubkey], limit: 200 }], { signal: AbortSignal.timeout(8000) }, ); const messages = dedupeNewest(events) .map(parseOffer) .filter((o): o is MarketplaceOffer => !!o); return toThreads(messages).filter((t) => t.awaiting === pubkey); }, }); } export function useOfferActions() { const { user } = useCurrentUser(); const { mutateAsync: publish } = useNostrPublish(); const queryClient = useQueryClient(); /** * Publish one negotiation message. `d` is unique per message so the thread is * append-only (a counter never overwrites the message it answers). */ const publishMessage = async ({ listing, counterparty, amount, currency = 'SATS', offerItem, note, status = 'open', replyTo, payment, paymentKind, paymentHash, }: { listing: { pubkey: string; identifier: string }; /** The other party — tagged `p` so the message reaches them. */ counterparty: string; amount?: number; currency?: string; /** In-kind offer (a swap/barter) — free text, e.g. "my 8.25 deck". */ offerItem?: string; note?: string; status?: OfferStatus; replyTo?: string; /** Payment pointer to attach (the seller sets this on an `accepted` message). */ payment?: string; paymentKind?: PaymentKind; paymentHash?: string; }) => { if (!user) throw new Error('Log in to make an offer.'); if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.'); // A buyer's messages are `offer`s; the listing owner's are `response`s. const role: OfferRole = user.pubkey === listing.pubkey ? 'response' : 'offer'; const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`; const tags: string[][] = [ ['d', `${user.pubkey}:${role}:${listing.identifier}:${Date.now().toString(36)}`], ['a', coord], ['role', role], ['status', status], ['p', counterparty], ['alt', 'SkateSpots: marketplace offer'], ]; if (replyTo) tags.push(['e', replyTo]); if (amount && amount > 0) tags.push(['amount', String(amount), currency]); if (offerItem?.trim()) tags.push(['item', offerItem.trim().slice(0, 120)]); if (payment?.trim()) { tags.push(['payment', payment.trim(), paymentKind ?? 'lnaddress']); if (paymentHash?.trim()) tags.push(['payment_hash', paymentHash.trim()]); } await publish({ kind: OFFER_KIND, content: note?.trim() || '', tags }); // Refresh every offer view so the new message/state (and the Pay button) // appears immediately instead of after the 60s stale window. queryClient.invalidateQueries({ queryKey: ['listing-offers'] }); queryClient.invalidateQueries({ queryKey: ['offer-threads-to-me'] }); queryClient.invalidateQueries({ queryKey: ['incoming-offers'] }); }; /** Buyer: open the negotiation. */ const createOffer = (args: { listing: { pubkey: string; identifier: string }; amount?: number; currency?: string; offerItem?: string; note?: string; }) => publishMessage({ ...args, counterparty: args.listing.pubkey }); /** Either side answers with a new amount and/or a different item offered. */ const counterOffer = (args: { listing: { pubkey: string; identifier: string }; previous: MarketplaceOffer; amount?: number; currency?: string; offerItem?: string; note?: string; }) => publishMessage({ listing: args.listing, counterparty: args.previous.author, amount: args.amount, currency: args.currency, offerItem: args.offerItem, note: args.note, status: 'open', replyTo: args.previous.id, }); /** Accept / decline / withdraw — always answering the current message. */ const respondToOffer = ( previous: MarketplaceOffer, status: Extract, note?: string, /** On accept, the seller may attach where to pay (a pointer + its kind). */ payment?: { pointer: string; kind?: PaymentKind; hash?: string }, ) => publishMessage({ listing: { pubkey: previous.listingPubkey, identifier: previous.listingIdentifier }, counterparty: previous.author, amount: status === 'accepted' ? previous.amount : undefined, currency: previous.currency, note: note ?? previous.message, status, replyTo: previous.id, payment: status === 'accepted' ? payment?.pointer : undefined, paymentKind: status === 'accepted' ? payment?.kind : undefined, paymentHash: status === 'accepted' ? payment?.hash : undefined, }); return { createOffer, counterOffer, respondToOffer }; }