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)
469 lines
17 KiB
TypeScript
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),
|
|
};
|
|
},
|
|
});
|
|
}
|