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:
parent
8833a15830
commit
fead9ca781
9 changed files with 444 additions and 45 deletions
18
AGENTS.md
18
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
93
src/hooks/useTrustAssertions.ts
Normal file
93
src/hooks/useTrustAssertions.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
35
src/hooks/useTrustBucket.ts
Normal file
35
src/hooks/useTrustBucket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
138
src/lib/nip85.ts
Normal 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
70
src/lib/trustLadder.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue