SkateSpots/src/hooks/useTrustScore.ts

141 lines
4.1 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from './useCurrentUser';
import { onlyVerified } from '@/lib/verifyEvents';
/**
* Hook to calculate trust score for a Nostr pubkey using Web of Trust (WoT).
*
* Trust scores are calculated using social graph analysis:
* - 100: Self (viewing your own content)
* - 90: Direct follow (you follow them)
* - 60: Friend-of-friend (you follow someone who follows them)
* - 30: Has NIP-05 verification
* - 0: No connection or verification
*
* @param targetPubkey - The pubkey to calculate trust score for
* @returns Trust score (0-100) or undefined if not available
*/
export function useTrustScore(targetPubkey: string | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery({
queryKey: ['trust-score', targetPubkey, user?.pubkey],
queryFn: async (c) => {
if (!targetPubkey) return undefined;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
try {
// Self score = 100
if (user?.pubkey && targetPubkey === user.pubkey) {
return 100;
}
// Get user's follow list (kind 3)
const sourcePubkey = user?.pubkey || '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
// Signature-verify follow lists before deriving any trust from them —
// an unverified kind-3 from a malicious relay could inflate a score.
const followListEvents = onlyVerified(await nostr.query(
[{ kinds: [3], authors: [sourcePubkey], limit: 1 }],
{ signal }
));
const followList = followListEvents[0];
if (!followList) {
return 0;
}
// Extract followed pubkeys from tags
const following = new Set(
followList.tags
.filter(([tag]) => tag === 'p')
.map(([, pubkey]) => pubkey)
);
// Direct follow = 90
if (following.has(targetPubkey)) {
return 90;
}
// Check friend-of-friend (2 hops)
const friendsFollowLists = onlyVerified(await nostr.query(
[{ kinds: [3], authors: Array.from(following), limit: 100 }],
{ signal }
));
for (const friendList of friendsFollowLists) {
const friendFollowing = friendList.tags
.filter(([tag]) => tag === 'p')
.map(([, pubkey]) => pubkey);
if (friendFollowing.includes(targetPubkey)) {
return 60;
}
}
// Check if they have NIP-05 verification (basic credibility)
const profileEvents = onlyVerified(await nostr.query(
[{ kinds: [0], authors: [targetPubkey], limit: 1 }],
{ signal }
));
if (profileEvents[0]) {
try {
const metadata = JSON.parse(profileEvents[0].content);
if (metadata.nip05) {
return 30;
}
} catch {
// Ignore JSON parse errors
}
}
// No trust signals found
return 0;
} catch (error) {
console.warn('[useTrustScore] Error calculating trust score:', error);
return undefined;
}
},
enabled: !!targetPubkey,
staleTime: 3600000, // Cache for 1 hour (trust scores change slowly)
gcTime: 86400000, // Keep in cache for 24 hours
retry: 1, // Only retry once on failure
});
}
/**
* Get trust badge configuration based on trust score
*/
export function getTrustBadgeConfig(score: number | undefined) {
if (score === undefined || score < 50) {
return {
show: false,
variant: 'outline' as const,
className: 'border-gray-500 text-gray-500',
label: 'UNVERIFIED',
icon: null,
};
}
if (score < 80) {
return {
show: true,
variant: 'outline' as const,
className: 'border-yellow-500 text-yellow-500',
label: 'TRUSTED',
icon: 'shield',
};
}
return {
show: true,
variant: 'default' as const,
className: 'bg-green-500 text-white',
label: 'VERIFIED',
icon: 'check',
};
}