SkateSpots/src/hooks/useTradeAttestations.ts
SkateSpots 3f51740f6a fix(map): stop the offline banner sticking; feat(trades): notify trade confirmations
Offline banner: it keyed off a single relay probe (SKATE_RELAY) while the map
reads from several relays, so it reported 'showing saved spots' while fresh data
loaded fine - and its retry only refetched spots, never re-probed, so it appeared
impossible to clear. Now the saved-spots banner is driven by deviceOffline
(!useOnlineStatus) only, is dismissable (re-armed when the connection returns),
and both retries call refetchSpots() AND relay.recheck(). The relay probe still
gates the 'nothing at all' card.

Trade confirmations: usePendingTradeConfirmations now returns createdAt/eventId,
so 'someone confirmed your trade' becomes a trade-confirm notification - the
missing final step. Bell card + OS title cover it; confirming back is what makes
the trade mutual and builds both reputations.
2026-09-29 21:06:04 +07:00

494 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
rating,
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;
/** Trade rating, 1–10 (skateboard decks). Optional. */
rating?: number;
/** 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 (typeof rating === 'number' && rating >= 1 && rating <= 10) {
tags.push(['rating', String(Math.round(rating))]);
}
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;
/**
* Distinct counterparties with at least one mutually-confirmed trade.
*
* This is the anti-collusion headline: two accounts trading only with each
* other can inflate `verified` without bound, but never `verifiedCounterparties`
* beyond 1. UI should lead with this instead of the raw count.
*/
verifiedCounterparties: 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, verifiedCounterparties: 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,
verifiedCounterparties: verifiedWith.size,
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;
/** When the counterparty attested (drives the notification ordering). */
createdAt: number;
/** The attestation event id — a stable notification key. */
eventId: 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,
createdAt: ev.created_at,
eventId: ev.id,
});
}
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),
};
},
});
}