SkateSpots/src/hooks/useTrustAssertions.ts
SkateSpots fead9ca781 feat(trust): spec-correct NIP-85 consumer + Verified/Unknown/Flagged badge
First slice of TRUST_SCORE.md.

- lib/nip85.ts: parse kind-10040 provider designations, resolve a provider
  with a house fallback, parse kind-30382 assertions (rank/followers/
  reporters/muters/hops; absent hops means no path, absence is not
  distrust), and author-check the provider.
- hooks/useTrustAssertions.ts: batch read from the resolved provider,
  signature-verified via onlyVerified + the author check.
- lib/trustLadder.ts: three buckets and thresholds (flagged = trusted
  reporters >= 2; verified rank >= 2).
- hooks/useTrustBucket.ts: prefers a NIP-85 assertion (verify + flag),
  falls back to the local WoT ladder (verify only).
- TrustBadge.tsx: now renders Verified / Unknown / Flagged with glyph,
  colour and tooltip; Unknown hidden by default, Flagged always shown.
  Reaches the map/tour/event cards, marketplace card, listing detail and
  coach detail through the shared component.

Open follow-ups (in TRUST_SCORE.md): batch the bucket at list level, a
'who reported' breakdown, a provider-choice setting, alerts, confidence
scoring.
2026-10-09 17:07:34 +07:00

93 lines
3.1 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { onlyVerified } from '@/lib/verifyEvents';
import {
HOUSE_TRUST_PROVIDER,
NIP85_PROVIDER_LIST_KIND,
NIP85_USER_ASSERTION_KIND,
parseProviderDesignation,
parseUserTrustAssertions,
resolveProvider,
type TrustProvider,
type UserTrustAssertion,
} from '@/lib/nip85';
const MAX_TARGETS = 500;
/**
* Read kind-30382 Trusted Assertions (NIP-85) for a batch of subjects, from the
* provider the viewer designates via their own kind-10040 — falling back to the
* configured house provider. Signatures are verified and the event author must
* equal the resolved provider before a score is used.
*
* A missing assertion yields no entry: that means "nothing published", which is
* NOT the same as distrust. Callers must treat absence as `unknown`.
*/
export function useTrustAssertions(pubkeys: string[]) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const targets = [...pubkeys].sort().slice(0, MAX_TARGETS);
return useQuery({
queryKey: ['trust-assertions', targets.join(','), user?.pubkey],
queryFn: async (c): Promise<Map<string, UserTrustAssertion>> => {
const out = new Map<string, UserTrustAssertion>();
if (targets.length === 0) return out;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
// 1. Resolve the provider: the viewer's own designation, else the house.
let provider: TrustProvider = HOUSE_TRUST_PROVIDER;
try {
if (user?.pubkey) {
const designationEvents = onlyVerified(await nostr.query(
[{ kinds: [NIP85_PROVIDER_LIST_KIND], authors: [user.pubkey], limit: 1 }],
{ signal },
));
const designation = parseProviderDesignation(designationEvents[0]);
provider = resolveProvider(
designation,
NIP85_USER_ASSERTION_KIND,
'rank',
HOUSE_TRUST_PROVIDER,
);
}
} catch {
// Designation lookup failed — the house provider is the fallback.
}
// 2. Fetch the assertions for these subjects from the resolved provider.
const filter = {
kinds: [NIP85_USER_ASSERTION_KIND],
authors: [provider.pubkey],
'#d': targets,
limit: targets.length,
};
try {
let events: NostrEvent[];
if (provider.relay) {
try {
events = await nostr.relay(provider.relay).query([filter], { signal });
} catch {
events = await nostr.query([filter], { signal });
}
} else {
events = await nostr.query([filter], { signal });
}
return parseUserTrustAssertions(onlyVerified(events), provider.pubkey);
} catch {
// Provider unreachable — absence, not distrust.
return out;
}
},
enabled: targets.length > 0,
staleTime: 60 * 60 * 1000, // 1h
gcTime: 24 * 60 * 60 * 1000,
retry: 1,
});
}