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.
370 lines
13 KiB
TypeScript
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 };
|
|
}
|