From fead9ca7812ead4331d467e04edc25bd40f866e4 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 9 Oct 2026 17:07:34 +0700 Subject: [PATCH] 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. --- AGENTS.md | 18 +++-- TRUST_SCORE.md | 25 +++++- src/components/TrustBadge.tsx | 96 +++++++++++++--------- src/hooks/useTrustAssertions.ts | 93 +++++++++++++++++++++ src/hooks/useTrustBucket.ts | 35 ++++++++ src/i18n/en.ts | 7 ++ src/i18n/es.ts | 7 ++ src/lib/nip85.ts | 138 ++++++++++++++++++++++++++++++++ src/lib/trustLadder.ts | 70 ++++++++++++++++ 9 files changed, 444 insertions(+), 45 deletions(-) create mode 100644 src/hooks/useTrustAssertions.ts create mode 100644 src/hooks/useTrustBucket.ts create mode 100644 src/lib/nip85.ts create mode 100644 src/lib/trustLadder.ts diff --git a/AGENTS.md b/AGENTS.md index b5ead4b..ecf0a81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2512,11 +2512,19 @@ downstream of **centralized identity + centralized conflict resolution**; the fi is identity at a lower level + **plural, viewer-applied filtering**. A full idea list was discussed this session but is deliberately **NOT started**. -**Related, also ideas-only:** `TRUST_SCORE.md` collects the trust-scoring review -(NIP-85 consumer with kind-10040 provider choice, a Verified/Unknown/Flagged -display, confidence-gated scoring, counterparty alerts). It is the concrete form -of the "labeler choice / plural filtering" thesis below. Not started; read it -before touching `useTrustScore` / `useRelatrScore` / `trustFilter`. +**Related:** `TRUST_SCORE.md` collects the trust-scoring review and plan. Its +**first slice has landed**: `src/lib/nip85.ts` (kind-10040 designation + +kind-30382 assertion parsing; absence = unknown, never distrust), +`src/hooks/useTrustAssertions.ts` (batch, signature + author verified), +`src/lib/trustLadder.ts` (buckets Verified/Unknown/Flagged; +`FLAGGED_REPORTERS_THRESHOLD` 2), `src/hooks/useTrustBucket.ts`, and +`components/TrustBadge.tsx` now renders the three buckets (Unknown hidden by +default, Flagged always shown). `TrustBadge` is used by the map/tour/event cards, +marketplace card, listing detail and coach detail, so those surfaces get it for +free. Still open: batch the bucket at list level (currently one provider query +per card), a "who reported" breakdown, a provider-choice setting, alerts, and +the confidence-gated scoring model. Check `TRUST_SCORE.md` before touching +`useTrustScore` / `useRelatrScore` / `trustFilter`. **Keep the door open (cheap):** admin keys and relay sets must stay *data you can swap*, not constants. Hardcoded `ADMIN_PUBKEYS` is fine now — converting it later diff --git a/TRUST_SCORE.md b/TRUST_SCORE.md index 2bb7a24..0d15cfa 100644 --- a/TRUST_SCORE.md +++ b/TRUST_SCORE.md @@ -1,9 +1,32 @@ # Trust scores — ideas and plan -Status: ideas / proposal. No code yet. +Status: first slice landed; the rest is proposal. No breaking changes. Scope: better trust for people and content across the map, The Lab, the marketplace, and moderation. +## First slice (landed) + +- `src/lib/nip85.ts` — kind-10040 designation parsing, provider resolution with a + house fallback, kind-30382 assertion parsing (with the absence rules baked in), + and batch parsing keyed by subject. +- `src/hooks/useTrustAssertions.ts` — batch read from the resolved provider; + signature-verified and author-checked. Absence yields no entry (= unknown). +- `src/lib/trustLadder.ts` — the three buckets and the two thresholds + (`FLAGGED_REPORTERS_THRESHOLD` 2, `VERIFIED_RANK_THRESHOLD` 2). +- `src/hooks/useTrustBucket.ts` — combines a NIP-85 assertion (verify + flag) with + the local ladder (verify only) into one bucket. +- `src/components/TrustBadge.tsx` — now renders Verified / Unknown / Flagged + (glyph + colour + tooltip). Unknown is hidden by default; Flagged always shows. + Because this is used by SpotCard, TourCard, EventCard, MarketplaceItemCard, + ListingDetailPage and CoachDetailPage, the mapping, Lab and marketplace all get + it for free. + +Still open from this slice: precompute the bucket at list level and pass it down +(one provider query per list instead of per card); a "who reported" breakdown +(the assertion carries counts, not identities); and a Settings control to pick +the provider instead of only using the designation or house default. + + Source material: the **GrapeRank** and **Trusted Assertions** specifications in the `NosFabrica/protocols` repository, plus a public reference implementation and client by the same group. NIP-85 (Trusted Assertions) is the diff --git a/src/components/TrustBadge.tsx b/src/components/TrustBadge.tsx index 26a7f8c..e1e8023 100644 --- a/src/components/TrustBadge.tsx +++ b/src/components/TrustBadge.tsx @@ -1,7 +1,8 @@ import { Badge } from '@/components/ui/badge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { Shield, CheckCircle2, AlertCircle } from 'lucide-react'; -import { useTrustScore, getTrustBadgeConfig } from '@/hooks/useTrustScore'; +import { CheckCircle2, HelpCircle, Flag } from 'lucide-react'; +import { useTrustBucket } from '@/hooks/useTrustBucket'; +import { bucketDescKey, bucketLabelKey, type TrustBucket } from '@/lib/trustLadder'; import { cn } from '@/lib/utils'; import { useTranslation } from '@/contexts/language'; @@ -10,26 +11,51 @@ interface TrustBadgeProps { className?: string; showLabel?: boolean; size?: 'sm' | 'md' | 'lg'; + /** + * Also render the neutral "Unknown" bucket. Off by default so a card row is + * not flooded with a badge on every author; surfaces with room can turn it on. + */ + showUnknown?: boolean; } -/** - * TrustBadge component displays a visual trust score indicator for a Nostr user. - * - * Trust levels: - * - 100: Self (your own content) - * - 90: Direct follow - * - 60: Friend-of-friend - * - 30: NIP-05 verified - * - 0: Unknown/unverified - */ -export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }: TrustBadgeProps) { - const { t } = useTranslation(); - const { data: trustScore, isLoading } = useTrustScore(pubkey); - const badge = getTrustBadgeConfig(trustScore); +const BUCKET_STYLE: Record< + TrustBucket, + { className: string; Icon: typeof CheckCircle2 } +> = { + verified: { + className: 'border-green-500/50 text-green-400 bg-green-500/10', + Icon: CheckCircle2, + }, + unknown: { + className: 'border-gray-500/50 text-gray-400', + Icon: HelpCircle, + }, + flagged: { + className: 'border-red-500/60 text-red-400 bg-red-500/10', + Icon: Flag, + }, +}; - if (isLoading || !badge.show) { - return null; - } +/** + * TrustBadge shows one of three buckets for an author: + * verified (someone in your network vouches), unknown (nothing said yet), or + * flagged (trusted people reported them). Prefers a NIP-85 assertion, falls + * back to the local web-of-trust score. Flagged always shows. + */ +export function TrustBadge({ + pubkey, + className, + showLabel = true, + size = 'sm', + showUnknown = false, +}: TrustBadgeProps) { + const { t } = useTranslation(); + const { bucket, assertion, isLoading } = useTrustBucket(pubkey); + + if (isLoading) return null; + if (bucket === 'unknown' && !showUnknown) return null; + + const { className: bucketClass, Icon } = BUCKET_STYLE[bucket]; const sizeClasses = { sm: 'text-[9px] px-1.5 py-0 h-4 gap-0.5', @@ -43,35 +69,27 @@ export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }: lg: 'h-4 w-4', }; - const getTooltipText = (score: number | undefined) => { - if (score === undefined) return t('walletUi.trust.unavailable'); - if (score === 100) return t('walletUi.trust.yourContent'); - if (score >= 90) return t('walletUi.trust.following'); - if (score >= 60) return t('walletUi.trust.friendOfFriend'); - if (score >= 30) return t('walletUi.trust.nip05'); - return t('walletUi.trust.unknown'); - }; - return ( - - {badge.icon === 'check' && } - {badge.icon === 'shield' && } - {!badge.icon && } - {showLabel && {badge.label}} + + {showLabel && {t(bucketLabelKey(bucket))}}
-

{getTooltipText(trustScore)}

-

- {t('walletUi.trust.help')} -

+

{t(bucketLabelKey(bucket))}

+

{t(bucketDescKey(bucket))}

+ {bucket === 'flagged' && assertion?.reporters !== undefined && assertion.reporters > 0 && ( +

+ {t('walletUi.trust.reportedByTrusted', { count: assertion.reporters })} +

+ )}
diff --git a/src/hooks/useTrustAssertions.ts b/src/hooks/useTrustAssertions.ts new file mode 100644 index 0000000..e1b78d8 --- /dev/null +++ b/src/hooks/useTrustAssertions.ts @@ -0,0 +1,93 @@ +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> => { + const out = new Map(); + 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, + }); +} diff --git a/src/hooks/useTrustBucket.ts b/src/hooks/useTrustBucket.ts new file mode 100644 index 0000000..fe59bc9 --- /dev/null +++ b/src/hooks/useTrustBucket.ts @@ -0,0 +1,35 @@ +import { useTrustScore } from './useTrustScore'; +import { useTrustAssertions } from './useTrustAssertions'; +import { bucketFromAssertion, bucketFromLocalScore, type TrustBucket } from '@/lib/trustLadder'; +import type { UserTrustAssertion } from '@/lib/nip85'; + +export interface TrustBucketResult { + bucket: TrustBucket; + /** Provider assertion backing the bucket, when one exists. */ + assertion?: UserTrustAssertion; + /** Local web-of-trust score (0-100), when available. */ + localScore?: number; + isLoading: boolean; +} + +/** + * Resolve a viewer-facing trust bucket for one pubkey. Prefers a NIP-85 + * assertion (it can both verify and flag); otherwise falls back to the local + * web-of-trust ladder (verify only — it has no negative signal). + */ +export function useTrustBucket(pubkey: string | undefined): TrustBucketResult { + const { data: localScore, isLoading: localLoading } = useTrustScore(pubkey); + const { data: assertions, isLoading: assertionsLoading } = useTrustAssertions( + pubkey ? [pubkey] : [], + ); + + const assertion = pubkey ? assertions?.get(pubkey) : undefined; + const bucket = assertion ? bucketFromAssertion(assertion) : bucketFromLocalScore(localScore); + + return { + bucket, + assertion, + localScore, + isLoading: localLoading || assertionsLoading, + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1b8a9ef..6feafa4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6143,6 +6143,13 @@ walletUi: { top10Hint: 'Reach the top 10 supporters', }, trust: { + bucketVerified: 'Verified', + bucketUnknown: 'Unknown', + bucketFlagged: 'Flagged', + bucketVerifiedDesc: 'Someone in your network vouches for this account.', + bucketUnknownDesc: 'No one in your network has vouched for this account yet.', + bucketFlaggedDesc: 'Trusted people reported this account.', + reportedByTrusted: 'Reported by {{count}} trusted people', unavailable: 'Trust score unavailable', yourContent: 'This is your content', following: 'You follow this user', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 3d518ec..fefc178 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6143,6 +6143,13 @@ walletUi: { top10Hint: 'Entra en el top 10 de patrocinadores', }, trust: { + bucketVerified: 'Verificado', + bucketUnknown: 'Desconocido', + bucketFlagged: 'Marcado', + bucketVerifiedDesc: 'Alguien de tu red respalda esta cuenta.', + bucketUnknownDesc: 'Nadie de tu red ha respaldado esta cuenta todavía.', + bucketFlaggedDesc: 'Personas de confianza han reportado esta cuenta.', + reportedByTrusted: 'Reportada por {{count}} personas de confianza', unavailable: 'Puntuación de confianza no disponible', yourContent: 'Esto es tu contenido', following: 'Sigues a este usuario', diff --git a/src/lib/nip85.ts b/src/lib/nip85.ts new file mode 100644 index 0000000..0ea9d71 --- /dev/null +++ b/src/lib/nip85.ts @@ -0,0 +1,138 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * NIP-85 Trusted Assertions — consumer helpers. + * + * A trust-score provider publishes kind `30382` addressable events asserting, + * from one observer's point of view, a score for a subject (the `d` tag). The + * observer designates which provider is authoritative for them via their own + * kind `10040` event. Everything here is pure parsing/resolution; the network + * lives in `useTrustAssertions`. + * + * Honesty rules from the spec that the UI must preserve: + * - an absent `hops` means "no known path" — never 0; + * - absence of an assertion is NOT distrust; + * - ranks from different observers/providers are not comparable; + * - presence of an assertion is NOT endorsement (read the counts). + */ + +export const NIP85_PROVIDER_LIST_KIND = 10040; +export const NIP85_USER_ASSERTION_KIND = 30382; + +export interface TrustProvider { + /** Provider signing pubkey (hex). */ + pubkey: string; + /** Relay hint where the provider publishes. */ + relay?: string; +} + +export interface UserTrustAssertion { + /** The subject (the assertion's `d` tag), hex pubkey. */ + pubkey: string; + /** Published rank 0-100 (influence x 100, 0.01 resolution). */ + rank: number; + /** Trusted-rater counts, when the provider supplies them. */ + followers?: number; + reporters?: number; + muters?: number; + /** Follow-path distance; absent means no known path (do not read as 0). */ + hops?: number; + /** The provider that made the assertion (event author). */ + provider: string; + createdAt: number; +} + +/** + * Community default ("house") provider, used when the viewer has no kind-10040 + * designation. Same service the app already used for NIP-85 scores. + */ +export const HOUSE_TRUST_PROVIDER: TrustProvider = { + pubkey: '6b3780ef2972e73d370b84a3e51e7aa9ae34bf412938dcfbd9c5f63b221416c8', + relay: 'wss://relay.contextvm.org', +}; + +function tag(event: NostrEvent, name: string): string | undefined { + return event.tags.find(([n]) => n === name)?.[1]; +} + +function intTag(event: NostrEvent, name: string): number | undefined { + const raw = tag(event, name); + if (raw === undefined) return undefined; + const value = Number.parseInt(raw, 10); + return Number.isFinite(value) ? value : undefined; +} + +/** + * Parse an observer's kind-10040 designation into a map of + * `":"` (e.g. `"30382:rank"`) to the chosen provider. + */ +export function parseProviderDesignation(event: NostrEvent | undefined): Map { + const map = new Map(); + if (!event || event.kind !== NIP85_PROVIDER_LIST_KIND) return map; + + for (const [name, pubkey, relay] of event.tags) { + // NIP-85 rows look like ["30382:rank", "", ""]. + if (!name || !pubkey) continue; + if (!/^\d+:[^:]+$/.test(name)) continue; + map.set(name, { pubkey, relay: relay || undefined }); + } + return map; +} + +/** Resolve the provider for one assertion type, falling back when undesignated. */ +export function resolveProvider( + designation: Map, + kind: number, + type: string, + fallback: TrustProvider, +): TrustProvider { + return designation.get(`${kind}:${type}`) ?? fallback; +} + +/** + * Parse a kind-30382 user assertion. Returns null for anything malformed, and + * for an event not authored by `expectedProvider` (the trust boundary — a + * rogue relay cannot inject a score under another provider's name). + */ +export function parseUserTrustAssertion( + event: NostrEvent, + expectedProvider?: string, +): UserTrustAssertion | null { + if (event.kind !== NIP85_USER_ASSERTION_KIND) return null; + if (expectedProvider && event.pubkey !== expectedProvider) return null; + + const subject = tag(event, 'd'); + const rankRaw = tag(event, 'rank'); + if (!subject || rankRaw === undefined) return null; + + const rank = Number.parseInt(rankRaw, 10); + if (!Number.isFinite(rank)) return null; + + return { + pubkey: subject, + rank: Math.max(0, Math.min(100, rank)), + followers: intTag(event, 'followers'), + reporters: intTag(event, 'reporters'), + muters: intTag(event, 'muters'), + hops: intTag(event, 'hops'), + provider: event.pubkey, + createdAt: event.created_at, + }; +} + +/** Parse a batch, keyed by subject pubkey. */ +export function parseUserTrustAssertions( + events: NostrEvent[], + expectedProvider?: string, +): Map { + const out = new Map(); + for (const event of events) { + const parsed = parseUserTrustAssertion(event, expectedProvider); + // Newest wins per subject (kind 30382 is addressable). + if (parsed) { + const current = out.get(parsed.pubkey); + if (!current || parsed.createdAt >= current.createdAt) out.set(parsed.pubkey, parsed); + } + } + return out; +} diff --git a/src/lib/trustLadder.ts b/src/lib/trustLadder.ts new file mode 100644 index 0000000..148117d --- /dev/null +++ b/src/lib/trustLadder.ts @@ -0,0 +1,70 @@ +import type { UserTrustAssertion } from '@/lib/nip85'; + +/** + * Three-bucket trust display, plus the small amount of policy it needs. + * + * Deliberately three states, not a ladder of gradations: + * - verified — someone in the viewer's network vouches for the account; + * - unknown — nothing said yet (a brand-new account is unknown, NOT bad); + * - flagged — enough TRUSTED people reported (or muted) the account. + * + * Flagged wins over the other two. Scores/thresholds stay a policy detail; the + * UI only ever shows a bucket plus the reason. + */ + +export type TrustBucket = 'verified' | 'unknown' | 'flagged'; + +/** + * Number of trusted reporters at or above which an account is flagged. The + * provider computes "trusted reporters" (reporters who themselves clear its + * threshold), so this is deliberately small. + */ +export const FLAGGED_REPORTERS_THRESHOLD = 2; + +/** + * Rank (0-100) at or above which a provider assertion counts as verified. The + * published house line is influence 0.02 = rank 2. + */ +export const VERIFIED_RANK_THRESHOLD = 2; + +/** Bucket from a provider assertion. */ +export function bucketFromAssertion(assertion: UserTrustAssertion): TrustBucket { + if ((assertion.reporters ?? 0) >= FLAGGED_REPORTERS_THRESHOLD) return 'flagged'; + if (assertion.rank >= VERIFIED_RANK_THRESHOLD) return 'verified'; + return 'unknown'; +} + +/** + * Bucket from the local WoT ladder (used when no provider assertion exists): + * `>= 60` means the viewer follows them or a friend-of-friend does — i.e. + * someone in the network already vouches. NIP-05-only (30) and unknown (0) + * stay unknown. The local ladder has no negative signal, so it never flags. + */ +export function bucketFromLocalScore(score: number | undefined): TrustBucket { + if (score !== undefined && score >= 60) return 'verified'; + return 'unknown'; +} + +/** Bucket name translation key (under the `walletUi.trust` namespace). */ +export function bucketLabelKey(bucket: TrustBucket): string { + switch (bucket) { + case 'verified': + return 'walletUi.trust.bucketVerified'; + case 'flagged': + return 'walletUi.trust.bucketFlagged'; + default: + return 'walletUi.trust.bucketUnknown'; + } +} + +/** One-line explainer translation key for a bucket. */ +export function bucketDescKey(bucket: TrustBucket): string { + switch (bucket) { + case 'verified': + return 'walletUi.trust.bucketVerifiedDesc'; + case 'flagged': + return 'walletUi.trust.bucketFlaggedDesc'; + default: + return 'walletUi.trust.bucketUnknownDesc'; + } +}