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
|
is identity at a lower level + **plural, viewer-applied filtering**. A full idea
|
||||||
list was discussed this session but is deliberately **NOT started**.
|
list was discussed this session but is deliberately **NOT started**.
|
||||||
|
|
||||||
**Related, also ideas-only:** `TRUST_SCORE.md` collects the trust-scoring review
|
**Related:** `TRUST_SCORE.md` collects the trust-scoring review and plan. Its
|
||||||
(NIP-85 consumer with kind-10040 provider choice, a Verified/Unknown/Flagged
|
**first slice has landed**: `src/lib/nip85.ts` (kind-10040 designation +
|
||||||
display, confidence-gated scoring, counterparty alerts). It is the concrete form
|
kind-30382 assertion parsing; absence = unknown, never distrust),
|
||||||
of the "labeler choice / plural filtering" thesis below. Not started; read it
|
`src/hooks/useTrustAssertions.ts` (batch, signature + author verified),
|
||||||
before touching `useTrustScore` / `useRelatrScore` / `trustFilter`.
|
`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
|
**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
|
swap*, not constants. Hardcoded `ADMIN_PUBKEYS` is fine now — converting it later
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,32 @@
|
||||||
# Trust scores — ideas and plan
|
# 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
|
Scope: better trust for people and content across the map, The Lab, the
|
||||||
marketplace, and moderation.
|
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
|
Source material: the **GrapeRank** and **Trusted Assertions** specifications in
|
||||||
the `NosFabrica/protocols` repository, plus a public reference
|
the `NosFabrica/protocols` repository, plus a public reference
|
||||||
implementation and client by the same group. NIP-85 (Trusted Assertions) is the
|
implementation and client by the same group. NIP-85 (Trusted Assertions) is the
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Shield, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { CheckCircle2, HelpCircle, Flag } from 'lucide-react';
|
||||||
import { useTrustScore, getTrustBadgeConfig } from '@/hooks/useTrustScore';
|
import { useTrustBucket } from '@/hooks/useTrustBucket';
|
||||||
|
import { bucketDescKey, bucketLabelKey, type TrustBucket } from '@/lib/trustLadder';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
|
@ -10,26 +11,51 @@ interface TrustBadgeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TrustBadge component displays a visual trust score indicator for a Nostr user.
|
* TrustBadge shows one of three buckets for an author:
|
||||||
*
|
* verified (someone in your network vouches), unknown (nothing said yet), or
|
||||||
* Trust levels:
|
* flagged (trusted people reported them). Prefers a NIP-85 assertion, falls
|
||||||
* - 100: Self (your own content)
|
* back to the local web-of-trust score. Flagged always shows.
|
||||||
* - 90: Direct follow
|
|
||||||
* - 60: Friend-of-friend
|
|
||||||
* - 30: NIP-05 verified
|
|
||||||
* - 0: Unknown/unverified
|
|
||||||
*/
|
*/
|
||||||
export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }: TrustBadgeProps) {
|
export function TrustBadge({
|
||||||
|
pubkey,
|
||||||
|
className,
|
||||||
|
showLabel = true,
|
||||||
|
size = 'sm',
|
||||||
|
showUnknown = false,
|
||||||
|
}: TrustBadgeProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: trustScore, isLoading } = useTrustScore(pubkey);
|
const { bucket, assertion, isLoading } = useTrustBucket(pubkey);
|
||||||
const badge = getTrustBadgeConfig(trustScore);
|
|
||||||
|
|
||||||
if (isLoading || !badge.show) {
|
if (isLoading) return null;
|
||||||
return null;
|
if (bucket === 'unknown' && !showUnknown) return null;
|
||||||
}
|
|
||||||
|
const { className: bucketClass, Icon } = BUCKET_STYLE[bucket];
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'text-[9px] px-1.5 py-0 h-4 gap-0.5',
|
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',
|
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 (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Badge
|
<Badge
|
||||||
variant={badge.variant}
|
variant="outline"
|
||||||
className={cn(sizeClasses[size], badge.className, className, 'cursor-help select-none')}
|
className={cn(sizeClasses[size], bucketClass, className, 'cursor-help select-none')}
|
||||||
>
|
>
|
||||||
{badge.icon === 'check' && <CheckCircle2 className={iconSizes[size]} />}
|
<Icon className={iconSizes[size]} />
|
||||||
{badge.icon === 'shield' && <Shield className={iconSizes[size]} />}
|
{showLabel && <span className="font-bold uppercase">{t(bucketLabelKey(bucket))}</span>}
|
||||||
{!badge.icon && <AlertCircle className={iconSizes[size]} />}
|
|
||||||
{showLabel && <span className="font-bold uppercase">{badge.label}</span>}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className="max-w-xs">
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-bold">{getTooltipText(trustScore)}</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('walletUi.trust.help')}
|
{t('walletUi.trust.reportedByTrusted', { count: assertion.reporters })}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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',
|
top10Hint: 'Reach the top 10 supporters',
|
||||||
},
|
},
|
||||||
trust: {
|
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',
|
unavailable: 'Trust score unavailable',
|
||||||
yourContent: 'This is your content',
|
yourContent: 'This is your content',
|
||||||
following: 'You follow this user',
|
following: 'You follow this user',
|
||||||
|
|
|
||||||
|
|
@ -6143,6 +6143,13 @@ walletUi: {
|
||||||
top10Hint: 'Entra en el top 10 de patrocinadores',
|
top10Hint: 'Entra en el top 10 de patrocinadores',
|
||||||
},
|
},
|
||||||
trust: {
|
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',
|
unavailable: 'Puntuación de confianza no disponible',
|
||||||
yourContent: 'Esto es tu contenido',
|
yourContent: 'Esto es tu contenido',
|
||||||
following: 'Sigues a este usuario',
|
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