SkateSpots/src/hooks/useTradeAttestations.ts
SkateSpots b91df1b4b0 feat(marketplace): trade confirmation requests (kind 31872) + fix boot smoke test
Marketplace:
- Buyer can 'Ask seller to confirm' a sold listing -> publishes a 31872
  Attestation Request (d/a/p), which the seller fulfils in one tap
- Seller sees incoming requests in the DM thread ('Confirm this sale?') and the
  fulfilling 31871 carries the spec's 'request' tag
- useTradeAttestationRequest / useIncomingTradeRequests / useListingTradeRequest
- NIP.md documents 31872 and states escrow is intentionally NOT implemented
  (coco-cashu has no P2PK/HTLC locking, wallet is native-only, and release-on-
  attestation needs a witness) so nothing looks trustless that isn't

Hardening:
- scripts/smoke-test.mjs navigated to /settings etc. WITHOUT a hash, so with
  HashRouter it only ever loaded the homepage and reported PASS. It now puts the
  route in the hash, covers settings/stash/collections/stats/marketplace, and
  fails on the 'This page slammed' error boundary (not just boot failure)
2026-09-26 20:47:29 +07:00

469 lines
17 KiB
TypeScript

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:<pubkey>:<d>") 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,
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;
/** 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 (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;
/** 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<TradeReputation>({
queryKey: ['trade-reputation', pubkey],
enabled: !!pubkey,
staleTime: 5 * 60 * 1000,
queryFn: async (c): Promise<TradeReputation> => {
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 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<string, NostrEvent[]>();
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<string>();
const verifiedWith = new Map<string, number>();
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,
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 };
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<string>();
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,
});
}
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<string>();
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<IncomingTradeRequest[]>({
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<string>();
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),
};
},
});
}