SkateSpots/src/hooks/useMarketplaceOffers.ts
SkateSpots 4b92759913 fix(marketplace): close P0 trade/offer bugs + deep flow audit
Deep audit (MARKETPLACE_FLOW_AUDIT.md): full trade/escrow/offers lifecycle,
severity-ranked findings, and a state-coverage matrix.

Fixes:
- Grid Mark as sold now opens MarkSoldDialog, so the kind-31871 attestation
  (buyer, method) is recorded from the grid too, not just the detail page.
- Offers accept attaches a payment pointer only when the accepting user is the
  listing owner (seller); previously the buyer could publish their own lud16 as
  the payee and misdirect payment.
- advertiseEscrowKey refuses to publish kind 0 before the profile loads, so it
  cannot overwrite name/picture/lud16 with a cashu_escrow-only profile.
- useListingOffers passes requireAny, so the offers panel error state actually
  fires on an outage (previously an outage looked like no offers).
- Offer messages invalidate the offer queries, so accept/counter/decline update
  immediately instead of after the stale window.
2026-10-09 21:35:06 +07:00

370 lines
13 KiB
TypeScript

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:<pubkey>:<d>`)
* 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> 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", "<value>", "<kind>"]` — 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<string, NostrEvent>();
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<string, MarketplaceOffer[]>();
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<OfferThread[]>({
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<OfferThread[]>({
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<OfferStatus, 'accepted' | 'declined' | 'withdrawn'>,
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 };
}