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.
98 lines
3.8 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
});
|
|
}
|