/** * Anti-sybil integrity signals for marketplace reputation. * * - `useBatchActivity` — how much REAL content a set of pubkeys has published * (spots + listings). A ring of fresh accounts has ~0, so its confirmations * can be weighted down to ~0 even when the trade graph looks busy. * - `useTradeRings` — a 2-hop look at the peer group: do a pubkey's trading * partners also trade **with each other**? A closed cluster is the signature * of a mutual-vouching ring. * * Both never throw; they degrade to neutral values. */ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { SKATE_SPOT_KIND, MARKETPLACE_ITEM_KIND } from '@/lib/types'; import { TRADE_ATTESTATION_KIND } from '@/hooks/useTradeAttestations'; import { onlyVerified } from '@/lib/verifyEvents'; /** Published content count per author (spots + listings). */ export function useBatchActivity(pubkeys: string[]) { const { nostr } = useNostr(); return useQuery({ queryKey: ['batch-activity', [...pubkeys].sort().join(',')], enabled: pubkeys.length > 0, staleTime: 30 * 60 * 1000, queryFn: async (c): Promise> => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); const counts = new Map(); pubkeys.forEach((pk) => counts.set(pk, 0)); try { const events = onlyVerified(await nostr.query( [{ kinds: [SKATE_SPOT_KIND, MARKETPLACE_ITEM_KIND], authors: pubkeys, limit: 500 }], { signal }, )); for (const ev of events) counts.set(ev.pubkey, (counts.get(ev.pubkey) ?? 0) + 1); } catch { /* leave the zeros */ } return counts; }, }); } export interface RingAnalysis { /** Distinct partners considered. */ partners: number; /** 0..1 — share of possible partner↔partner trades that exist. */ closedness: number; /** A self-contained group: ≥3 partners who all trade with one another. */ isRing: boolean; } /** * Is the pubkey's mutual-trade partner set a **closed cluster**? We fetch each * partner's attestations (one query) and count edges between the partners * themselves — a full clique means they vouch for each other, not the wider * scene. */ export function useTradeRings(pubkey: string | undefined, partners: string[]) { const { nostr } = useNostr(); const partnerKey = [...partners].sort().join(','); return useQuery({ queryKey: ['trade-rings', pubkey, partnerKey], enabled: !!pubkey && partners.length >= 3, staleTime: 10 * 60 * 1000, queryFn: async (c): Promise => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); const partnerSet = new Set(partners); const members = new Set([...(pubkey ? [pubkey] : []), ...partners]); const events = onlyVerified(await nostr.query( [{ kinds: [TRADE_ATTESTATION_KIND], authors: partners, limit: partners.length * 50 }], { signal }, )); const partnerPairs = new Set(); for (const ev of events) { const counterparty = ev.tags.find(([n]) => n === 'p')?.[1]; if (!counterparty || counterparty === ev.pubkey) continue; if (!members.has(ev.pubkey) || !members.has(counterparty)) continue; // Only edges BETWEEN partners (the subject's own edges don't count). if (partnerSet.has(ev.pubkey) && partnerSet.has(counterparty)) { partnerPairs.add([ev.pubkey, counterparty].sort().join(':')); } } const possible = (partners.length * (partners.length - 1)) / 2; const closedness = possible > 0 ? partnerPairs.size / possible : 0; return { partners: partners.length, closedness, isRing: partners.length >= 3 && closedness >= 0.5, }; }, }); }