SkateSpots/src/hooks/useTradeIntegrity.ts
SkateSpots 864a384f60 feat(reputation): activity weighting + closed-ring detection
useTradeIntegrity.ts adds two anti-sybil levers to TradeReputationCard:
- useBatchActivity counts each counterparty's published spots+listings and weights
  their contribution by min(1, activity/3), so a ring of empty accounts
  contributes ~0 even when cross-confirmed.
- useTradeRings does a 2-hop query of the partners' own attestations and measures
  closedness (share of partner-partner trades); >=3 partners with >=0.5 closedness
  flags a closed cluster. PoW (NIP-13) remains, but needs mining at publish.
2026-10-01 16:26:30 +07:00

98 lines
3.8 KiB
TypeScript

/**
* 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<Map<string, number>> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const counts = new Map<string, number>();
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<RingAnalysis> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
const partnerSet = new Set(partners);
const members = new Set<string>([...(pubkey ? [pubkey] : []), ...partners]);
const events = onlyVerified(await nostr.query(
[{ kinds: [TRADE_ATTESTATION_KIND], authors: partners, limit: partners.length * 50 }],
{ signal },
));
const partnerPairs = new Set<string>();
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,
};
},
});
}