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.
This commit is contained in:
SkateSpots 2026-10-09 17:07:34 +07:00
parent 8833a15830
commit fead9ca781
9 changed files with 444 additions and 45 deletions

View file

@ -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

View file

@ -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

View file

@ -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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={badge.variant}
className={cn(sizeClasses[size], badge.className, className, 'cursor-help select-none')}
<Badge
variant="outline"
className={cn(sizeClasses[size], bucketClass, className, 'cursor-help select-none')}
>
{badge.icon === 'check' && <CheckCircle2 className={iconSizes[size]} />}
{badge.icon === 'shield' && <Shield className={iconSizes[size]} />}
{!badge.icon && <AlertCircle className={iconSizes[size]} />}
{showLabel && <span className="font-bold uppercase">{badge.label}</span>}
<Icon className={iconSizes[size]} />
{showLabel && <span className="font-bold uppercase">{t(bucketLabelKey(bucket))}</span>}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="space-y-1">
<p className="font-bold">{getTooltipText(trustScore)}</p>
<p className="text-xs text-muted-foreground">
{t('walletUi.trust.help')}
</p>
<p className="font-bold">{t(bucketLabelKey(bucket))}</p>
<p className="text-xs text-muted-foreground">{t(bucketDescKey(bucket))}</p>
{bucket === 'flagged' && assertion?.reporters !== undefined && assertion.reporters > 0 && (
<p className="text-xs text-muted-foreground">
{t('walletUi.trust.reportedByTrusted', { count: assertion.reporters })}
</p>
)}
</div>
</TooltipContent>
</Tooltip>

View file

@ -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<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,
});
}

View file

@ -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,
};
}

View file

@ -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',

View file

@ -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',

138
src/lib/nip85.ts Normal file
View file

@ -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
* `"<kind>:<type>"` (e.g. `"30382:rank"`) to the chosen provider.
*/
export function parseProviderDesignation(event: NostrEvent | undefined): Map<string, TrustProvider> {
const map = new Map<string, TrustProvider>();
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", "<provider hex>", "<relay url>"].
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<string, TrustProvider>,
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<string, UserTrustAssertion> {
const out = new Map<string, UserTrustAssertion>();
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;
}

70
src/lib/trustLadder.ts Normal file
View file

@ -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';
}
}