SkateSpots/src/hooks/useTradeAttestations.ts
SkateSpots caa5f8c56e fix: tier 4 — offer role trust, proof replay, crew alerts, error/nav i18n
- H13 offer threads: the seller is ALWAYS the listing author (derived from the
  event pubkey), never a self-declared role tag — a non-owner can no longer
  appear as the seller with a payment pointer.
- H12 trade reputation: a preimage/deposit proof counts at most once, so a real
  payment can't be reused across attestations to inflate payment-verified.
- Crew alerts: listed crews are filtered against the user's own membership
  confirmation labels (one batched query), so a confirmed member no longer gets
  a phantom 'confirm to join' badge/notification when the crew is edited.
- Error boundaries (app + route) and DesktopNavManager are translated; the
  class boundary renders a translated functional ErrorFallback. New batch4
  namespaces errorsUi.* / desktopNav.*.
2026-10-02 23:22:13 +07:00

562 lines
21 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 { verifyZapReceipt } from '@/lib/zapReceipt';
import { verifyReceiptProof } from '@/lib/receipts';
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,
paymentProof,
depositProof,
}: {
/** 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;
/** A verifiable Lightning receipt: sha256(preimage) === paymentHash. */
paymentProof?: { preimage: string; paymentHash: string };
/** A refundable swap-deposit receipt (barter) — verified the same way. */
depositProof?: { preimage: string; paymentHash: 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]);
// Verifiable receipts (anyone can check sha256(preimage) === payment_hash).
if (paymentProof?.preimage && paymentProof.paymentHash) {
tags.push(['proof', 'lnpreimage', paymentProof.preimage, paymentProof.paymentHash]);
}
if (depositProof?.preimage && depositProof.paymentHash) {
tags.push(['proof', 'deposit', depositProof.preimage, depositProof.paymentHash]);
}
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;
/**
* Mutually-confirmed trades that ALSO carry a `proof` tag (a payment claim).
* ⚠️ The `proof` is self-reported and the app does NOT verify it — it is a
* stronger *claim*, never evidence of payment. Show it as "with payment proof",
* not "verified".
*/
withPaymentProof: number;
/**
* Trades whose `proof` is a **verified** zap receipt (kind 9735 — signature,
* recipient and amount all checked). The only client-verifiable tier today.
*/
paymentVerified: 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, withPaymentProof: 0, paymentVerified: 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;
let withPaymentProof = 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);
}
// A `proof` tag is a payment CLAIM, not verification — count it on its
// own tier so it is never confused with a confirmed trade.
if (attestations.some((e) => e.tags.some(([t]) => t === 'proof'))) {
withPaymentProof++;
}
}
// 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);
}
}
}
}
// Payment-VERIFIED tier — count only proofs we can actually check:
// • a Lightning preimage (`sha256(preimage) === payment_hash`), and
// • a NIP-57 zap receipt (signature + recipient + amount, fetched below).
const zapProofs = new Map<string, string>(); // receiptId → expected seller
const usedPreimages = new Set<string>(); // one payment counts once
let paymentVerified = 0;
for (const ev of unique) {
const proof = ev.tags.find(([t]) => t === 'proof');
if (!proof) continue;
if ((proof[1] === 'lnpreimage' || proof[1] === 'deposit') && proof[2] && proof[3]) {
// SECURITY: a real preimage reused across many attestations must not
// inflate the "payment-verified" tier — count each preimage once.
const preimage = proof[2];
if (!usedPreimages.has(preimage) && verifyReceiptProof(preimage, proof[3])) {
usedPreimages.add(preimage);
paymentVerified += 1;
}
} else if (proof[1] === 'zap' && proof[2]) {
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
if (coord) zapProofs.set(proof[2], coord.pubkey);
}
}
if (zapProofs.size > 0) {
const receipts = await nostr.query(
[{ kinds: [9735], ids: [...zapProofs.keys()], limit: 100 }],
{ signal },
);
for (const r of receipts) {
const expected = zapProofs.get(r.id);
if (expected && verifyZapReceipt(r, expected).valid) paymentVerified += 1;
}
}
return {
sales,
purchases,
verified,
withPaymentProof,
paymentVerified,
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),
};
},
});
}