diff --git a/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx b/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx
new file mode 100644
index 0000000..490afb1
--- /dev/null
+++ b/src/components/nostr/kinds/TrustedAssertionDetailRenderer.tsx
@@ -0,0 +1,347 @@
+import { NostrEvent } from "@/types/nostr";
+import { UserName } from "../UserName";
+import {
+ getAssertionSubject,
+ getAssertionTags,
+ getUserAssertionData,
+ getEventAssertionData,
+ getExternalAssertionData,
+ getExternalAssertionTypes,
+ ASSERTION_KIND_LABELS,
+ ASSERTION_TAG_LABELS,
+} from "@/lib/nip85-helpers";
+import { formatTimestamp } from "@/hooks/useLocale";
+import { BarChart3, User, FileText, Link, Hash } from "lucide-react";
+
+/**
+ * Rank visualization bar
+ */
+function RankBar({ rank }: { rank: number }) {
+ const clamped = Math.min(100, Math.max(0, rank));
+ return (
+
+ );
+}
+
+/**
+ * Metric row for detail table
+ */
+function MetricRow({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | number;
+}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+/**
+ * Subject header section based on kind
+ */
+function SubjectHeader({ kind, subject }: { kind: number; subject: string }) {
+ const icon =
+ kind === 30382 ? (
+
+ ) : kind === 30385 ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {icon}
+ {kind === 30382 ? (
+
+ ) : (
+ {subject}
+ )}
+
+ );
+}
+
+/**
+ * User assertion metrics (kind 30382)
+ */
+function UserMetrics({ event }: { event: NostrEvent }) {
+ const data = getUserAssertionData(event);
+
+ const metrics: { label: string; value: string | number }[] = [];
+
+ if (data.followers !== undefined)
+ metrics.push({
+ label: "Followers",
+ value: data.followers.toLocaleString(),
+ });
+ if (data.postCount !== undefined)
+ metrics.push({ label: "Posts", value: data.postCount.toLocaleString() });
+ if (data.replyCount !== undefined)
+ metrics.push({ label: "Replies", value: data.replyCount.toLocaleString() });
+ if (data.reactionsCount !== undefined)
+ metrics.push({
+ label: "Reactions",
+ value: data.reactionsCount.toLocaleString(),
+ });
+ if (data.zapAmountReceived !== undefined)
+ metrics.push({
+ label: "Zaps Received",
+ value: `${data.zapAmountReceived.toLocaleString()} sats`,
+ });
+ if (data.zapAmountSent !== undefined)
+ metrics.push({
+ label: "Zaps Sent",
+ value: `${data.zapAmountSent.toLocaleString()} sats`,
+ });
+ if (data.zapCountReceived !== undefined)
+ metrics.push({
+ label: "Zap Count Received",
+ value: data.zapCountReceived.toLocaleString(),
+ });
+ if (data.zapCountSent !== undefined)
+ metrics.push({
+ label: "Zap Count Sent",
+ value: data.zapCountSent.toLocaleString(),
+ });
+ if (data.zapAvgAmountDayReceived !== undefined)
+ metrics.push({
+ label: "Avg Zap/Day Received",
+ value: `${data.zapAvgAmountDayReceived.toLocaleString()} sats`,
+ });
+ if (data.zapAvgAmountDaySent !== undefined)
+ metrics.push({
+ label: "Avg Zap/Day Sent",
+ value: `${data.zapAvgAmountDaySent.toLocaleString()} sats`,
+ });
+ if (data.reportsReceived !== undefined)
+ metrics.push({
+ label: "Reports Received",
+ value: data.reportsReceived.toLocaleString(),
+ });
+ if (data.reportsSent !== undefined)
+ metrics.push({
+ label: "Reports Sent",
+ value: data.reportsSent.toLocaleString(),
+ });
+ if (data.firstCreatedAt !== undefined)
+ metrics.push({
+ label: "First Post",
+ value: formatTimestamp(data.firstCreatedAt, "long"),
+ });
+ if (data.activeHoursStart !== undefined && data.activeHoursEnd !== undefined)
+ metrics.push({
+ label: "Active Hours (UTC)",
+ value: `${data.activeHoursStart}:00 - ${data.activeHoursEnd}:00`,
+ });
+
+ return (
+ <>
+ {metrics.length > 0 && (
+
+ {metrics.map((m) => (
+
+ ))}
+
+ )}
+ {data.topics && data.topics.length > 0 && (
+
+
Topics
+
+ {data.topics.map((t) => (
+
+
+ {t}
+
+ ))}
+
+
+ )}
+ >
+ );
+}
+
+/**
+ * Event/address assertion metrics (kind 30383/30384)
+ */
+function EventMetrics({ event }: { event: NostrEvent }) {
+ const data = getEventAssertionData(event);
+
+ const metrics: { label: string; value: string | number }[] = [];
+
+ if (data.commentCount !== undefined)
+ metrics.push({
+ label: "Comments",
+ value: data.commentCount.toLocaleString(),
+ });
+ if (data.quoteCount !== undefined)
+ metrics.push({ label: "Quotes", value: data.quoteCount.toLocaleString() });
+ if (data.repostCount !== undefined)
+ metrics.push({
+ label: "Reposts",
+ value: data.repostCount.toLocaleString(),
+ });
+ if (data.reactionCount !== undefined)
+ metrics.push({
+ label: "Reactions",
+ value: data.reactionCount.toLocaleString(),
+ });
+ if (data.zapCount !== undefined)
+ metrics.push({ label: "Zap Count", value: data.zapCount.toLocaleString() });
+ if (data.zapAmount !== undefined)
+ metrics.push({
+ label: "Zap Amount",
+ value: `${data.zapAmount.toLocaleString()} sats`,
+ });
+
+ if (metrics.length === 0) return null;
+
+ return (
+
+ {metrics.map((m) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * External assertion metrics (kind 30385)
+ */
+function ExternalMetrics({ event }: { event: NostrEvent }) {
+ const data = getExternalAssertionData(event);
+ const types = getExternalAssertionTypes(event);
+
+ const metrics: { label: string; value: string | number }[] = [];
+
+ if (data.commentCount !== undefined)
+ metrics.push({
+ label: "Comments",
+ value: data.commentCount.toLocaleString(),
+ });
+ if (data.reactionCount !== undefined)
+ metrics.push({
+ label: "Reactions",
+ value: data.reactionCount.toLocaleString(),
+ });
+
+ return (
+ <>
+ {types.length > 0 && (
+
+
Type
+
+ {types.map((t) => (
+
+ {t}
+
+ ))}
+
+
+ )}
+ {metrics.length > 0 && (
+
+ {metrics.map((m) => (
+
+ ))}
+
+ )}
+ >
+ );
+}
+
+/**
+ * Fallback: show any unrecognized tags as raw rows
+ */
+function RawAssertionTags({ event }: { event: NostrEvent }) {
+ const tags = getAssertionTags(event);
+ const knownTags = new Set(Object.keys(ASSERTION_TAG_LABELS));
+ // Also filter topics since they're shown separately
+ const unknownTags = tags.filter(
+ (t) => !knownTags.has(t.name) && t.name !== "t",
+ );
+
+ if (unknownTags.length === 0) return null;
+
+ return (
+
+
Other Tags
+
+ {unknownTags.map((t, i) => (
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Trusted Assertion Detail Renderer (Kinds 30382-30385)
+ */
+export function TrustedAssertionDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ const subject = getAssertionSubject(event);
+ const kindLabel = ASSERTION_KIND_LABELS[event.kind] || "Assertion";
+ const tags = getAssertionTags(event);
+ const rankTag = tags.find((t) => t.name === "rank");
+
+ return (
+
+ {/* Header */}
+
+
+
{kindLabel}
+
+
+ {/* Provider */}
+
+ Provider:
+
+
+
+ {/* Subject */}
+ {subject &&
}
+
+ {/* Rank */}
+ {rankTag && (
+
+ Rank
+
+
+ )}
+
+ {/* Kind-specific metrics */}
+ {event.kind === 30382 &&
}
+ {(event.kind === 30383 || event.kind === 30384) && (
+
+ )}
+ {event.kind === 30385 &&
}
+
+ {/* Raw/unknown tags */}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/TrustedAssertionRenderer.tsx b/src/components/nostr/kinds/TrustedAssertionRenderer.tsx
new file mode 100644
index 0000000..d62aa9e
--- /dev/null
+++ b/src/components/nostr/kinds/TrustedAssertionRenderer.tsx
@@ -0,0 +1,198 @@
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { UserName } from "../UserName";
+import {
+ getAssertionSubject,
+ getAssertionTags,
+ getUserAssertionData,
+ getEventAssertionData,
+ getExternalAssertionData,
+ ASSERTION_KIND_LABELS,
+ ASSERTION_TAG_LABELS,
+} from "@/lib/nip85-helpers";
+import { BarChart3 } from "lucide-react";
+
+/**
+ * Subject display based on assertion kind
+ */
+function AssertionSubject({
+ kind,
+ subject,
+}: {
+ kind: number;
+ subject: string;
+}) {
+ if (kind === 30382) {
+ // User: show as UserName
+ return ;
+ }
+
+ if (kind === 30384) {
+ // Addressable event: kind:pubkey:d-tag
+ const parts = subject.split(":");
+ if (parts.length >= 3) {
+ return (
+
+ {parts[0]}:{parts[1].slice(0, 8)}...:{parts[2] || "*"}
+
+ );
+ }
+ }
+
+ // Event ID (30383) or NIP-73 identifier (30385)
+ return (
+
+ {kind === 30383 ? `${subject.slice(0, 16)}...` : subject}
+
+ );
+}
+
+/**
+ * Compact metrics preview — shows rank + top metrics
+ */
+function MetricsPreview({
+ event,
+}: {
+ event: { kind: number } & BaseEventProps["event"];
+}) {
+ const tags = getAssertionTags(event);
+ const rankTag = tags.find((t) => t.name === "rank");
+
+ // Get kind-specific summary metrics
+ let summaryMetrics: { label: string; value: string }[] = [];
+
+ if (event.kind === 30382) {
+ const data = getUserAssertionData(event);
+ if (data.followers !== undefined)
+ summaryMetrics.push({
+ label: "Followers",
+ value: data.followers.toLocaleString(),
+ });
+ if (data.postCount !== undefined)
+ summaryMetrics.push({
+ label: "Posts",
+ value: data.postCount.toLocaleString(),
+ });
+ if (data.zapAmountReceived !== undefined)
+ summaryMetrics.push({
+ label: "Zaps Recd",
+ value: `${data.zapAmountReceived.toLocaleString()} sats`,
+ });
+ } else if (event.kind === 30383 || event.kind === 30384) {
+ const data = getEventAssertionData(event);
+ if (data.reactionCount !== undefined)
+ summaryMetrics.push({
+ label: "Reactions",
+ value: data.reactionCount.toLocaleString(),
+ });
+ if (data.commentCount !== undefined)
+ summaryMetrics.push({
+ label: "Comments",
+ value: data.commentCount.toLocaleString(),
+ });
+ if (data.zapAmount !== undefined)
+ summaryMetrics.push({
+ label: "Zaps",
+ value: `${data.zapAmount.toLocaleString()} sats`,
+ });
+ } else if (event.kind === 30385) {
+ const data = getExternalAssertionData(event);
+ if (data.reactionCount !== undefined)
+ summaryMetrics.push({
+ label: "Reactions",
+ value: data.reactionCount.toLocaleString(),
+ });
+ if (data.commentCount !== undefined)
+ summaryMetrics.push({
+ label: "Comments",
+ value: data.commentCount.toLocaleString(),
+ });
+ }
+
+ // Fall back to raw tags if no structured data
+ if (summaryMetrics.length === 0) {
+ summaryMetrics = tags
+ .filter((t) => t.name !== "rank" && t.name !== "t")
+ .slice(0, 3)
+ .map((t) => ({
+ label: ASSERTION_TAG_LABELS[t.name] || t.name,
+ value: t.value,
+ }));
+ } else {
+ summaryMetrics = summaryMetrics.slice(0, 3);
+ }
+
+ return (
+
+ {/* Rank badge */}
+ {rankTag && (
+
+
+
+
{rankTag.value}/100
+
+
+ )}
+
+ {/* Summary metrics */}
+ {summaryMetrics.length > 0 && (
+
+ {summaryMetrics.map((m) => (
+
+ {m.value}{" "}
+ {m.label}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * Trusted Assertion Renderer — Feed View (Kinds 30382-30385)
+ * Shared renderer for all four NIP-85 assertion event kinds
+ */
+export function TrustedAssertionRenderer({ event }: BaseEventProps) {
+ const subject = getAssertionSubject(event);
+ const kindLabel = ASSERTION_KIND_LABELS[event.kind] || "Assertion";
+
+ return (
+
+
+
+
+
+
+ {kindLabel}
+
+
+
+
+ {/* Subject */}
+ {subject && (
+
+ )}
+
+ {/* Metrics preview */}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/TrustedProviderListDetailRenderer.tsx b/src/components/nostr/kinds/TrustedProviderListDetailRenderer.tsx
new file mode 100644
index 0000000..8fe1c39
--- /dev/null
+++ b/src/components/nostr/kinds/TrustedProviderListDetailRenderer.tsx
@@ -0,0 +1,81 @@
+import { NostrEvent } from "@/types/nostr";
+import { UserName } from "../UserName";
+import {
+ getTrustedProviders,
+ hasEncryptedProviders,
+ formatKindTag,
+} from "@/lib/nip85-helpers";
+import { Shield, Lock, Radio } from "lucide-react";
+
+/**
+ * Trusted Provider List Detail Renderer (Kind 10040)
+ * Full table of all public provider entries
+ */
+export function TrustedProviderListDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ const providers = getTrustedProviders(event);
+ const hasEncrypted = hasEncryptedProviders(event);
+
+ return (
+
+ {/* Header */}
+
+
+
Trusted Providers
+
+
+ {/* Author */}
+
+ Declared by:
+
+
+
+ {/* Encrypted notice */}
+ {hasEncrypted && (
+
+
+
+ This list contains encrypted provider entries (NIP-44) that cannot
+ be displayed.
+
+
+ )}
+
+ {/* Provider table */}
+ {providers.length > 0 ? (
+
+ {/* Table header */}
+
+ Metric
+ Provider
+ Relay
+
+
+ {/* Rows */}
+ {providers.map((p, i) => (
+
+
+ {formatKindTag(p.kindTag)}
+
+
+
+
+ {p.relay}
+
+
+ ))}
+
+ ) : (
+
+ No public provider entries found.
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/TrustedProviderListRenderer.tsx b/src/components/nostr/kinds/TrustedProviderListRenderer.tsx
new file mode 100644
index 0000000..a45c7f3
--- /dev/null
+++ b/src/components/nostr/kinds/TrustedProviderListRenderer.tsx
@@ -0,0 +1,72 @@
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { UserName } from "../UserName";
+import {
+ getTrustedProviders,
+ hasEncryptedProviders,
+ formatKindTag,
+} from "@/lib/nip85-helpers";
+import { Shield, Lock } from "lucide-react";
+
+/**
+ * Trusted Provider List Renderer — Feed View (Kind 10040)
+ * Shows the user's declared trusted assertion providers
+ */
+export function TrustedProviderListRenderer({ event }: BaseEventProps) {
+ const providers = getTrustedProviders(event);
+ const hasEncrypted = hasEncryptedProviders(event);
+ const previewProviders = providers.slice(0, 4);
+
+ return (
+
+
+
+
+
+ Trusted Providers
+
+
+
+ {/* Provider count */}
+
+ {providers.length} public provider{providers.length !== 1 ? "s" : ""}
+ {hasEncrypted && " + encrypted entries"}
+
+
+ {/* Preview of provider entries */}
+ {previewProviders.length > 0 && (
+
+ {previewProviders.map((p, i) => (
+
+
+ {formatKindTag(p.kindTag)}
+
+ -
+
+
+ ))}
+ {providers.length > 4 && (
+
+ +{providers.length - 4} more...
+
+ )}
+
+ )}
+
+ {/* Encrypted notice */}
+ {hasEncrypted && providers.length === 0 && (
+
+
+ All provider entries are encrypted
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 6f966d3..2732fc4 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -161,6 +161,10 @@ import { PollDetailRenderer } from "./PollDetailRenderer";
import { PollResponseRenderer } from "./PollResponseRenderer";
import { ReportRenderer, ReportDetailRenderer } from "./ReportRenderer";
import { ThreadRenderer } from "./ThreadRenderer";
+import { TrustedAssertionRenderer } from "./TrustedAssertionRenderer";
+import { TrustedAssertionDetailRenderer } from "./TrustedAssertionDetailRenderer";
+import { TrustedProviderListRenderer } from "./TrustedProviderListRenderer";
+import { TrustedProviderListDetailRenderer } from "./TrustedProviderListDetailRenderer";
/**
* Registry of kind-specific renderers
@@ -215,6 +219,7 @@ const kindRenderers: Record> = {
10015: InterestListRenderer, // Interest List (NIP-51)
10020: MediaFollowListRenderer, // Media Follow List (NIP-51)
10030: EmojiListRenderer, // User Emoji List (NIP-51)
+ 10040: TrustedProviderListRenderer, // Trusted Provider List (NIP-85)
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
@@ -238,6 +243,10 @@ const kindRenderers: Record> = {
30166: RelayDiscoveryRenderer, // Relay Discovery (NIP-66)
30267: ZapstoreAppSetRenderer, // Zapstore App Collection
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
+ 30382: TrustedAssertionRenderer, // User Assertion (NIP-85)
+ 30383: TrustedAssertionRenderer, // Event Assertion (NIP-85)
+ 30384: TrustedAssertionRenderer, // Address Assertion (NIP-85)
+ 30385: TrustedAssertionRenderer, // External Assertion (NIP-85)
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
30617: RepositoryRenderer, // Repository (NIP-34)
@@ -321,6 +330,7 @@ const detailRenderers: Record<
10004: CommunityListDetailRenderer, // Community List Detail (NIP-51)
10005: ChannelListDetailRenderer, // Channel List Detail (NIP-51)
10015: InterestListDetailRenderer, // Interest List Detail (NIP-51)
+ 10040: TrustedProviderListDetailRenderer, // Trusted Provider List Detail (NIP-85)
10020: MediaFollowListDetailRenderer, // Media Follow List Detail (NIP-51)
10030: EmojiListDetailRenderer, // User Emoji List Detail (NIP-51)
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
@@ -344,6 +354,10 @@ const detailRenderers: Record<
30166: RelayDiscoveryDetailRenderer, // Relay Discovery Detail (NIP-66)
30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
+ 30382: TrustedAssertionDetailRenderer, // User Assertion Detail (NIP-85)
+ 30383: TrustedAssertionDetailRenderer, // Event Assertion Detail (NIP-85)
+ 30384: TrustedAssertionDetailRenderer, // Address Assertion Detail (NIP-85)
+ 30385: TrustedAssertionDetailRenderer, // External Assertion Detail (NIP-85)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index 791ef06..e880aab 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -829,6 +829,13 @@ export const EVENT_KINDS: Record = {
// nip: "Marmot",
// icon: Key,
// },
+ 10040: {
+ kind: 10040,
+ name: "Trusted Providers",
+ description: "Trusted Assertion Provider List",
+ nip: "85",
+ icon: Shield,
+ },
10063: {
kind: 10063,
name: "Blossom Server List",
@@ -1220,6 +1227,34 @@ export const EVENT_KINDS: Record = {
nip: "53",
icon: Video,
},
+ 30382: {
+ kind: 30382,
+ name: "User Assertion",
+ description: "Trusted Assertion: User",
+ nip: "85",
+ icon: BarChart3,
+ },
+ 30383: {
+ kind: 30383,
+ name: "Event Assertion",
+ description: "Trusted Assertion: Event",
+ nip: "85",
+ icon: BarChart3,
+ },
+ 30384: {
+ kind: 30384,
+ name: "Address Assertion",
+ description: "Trusted Assertion: Addressable Event",
+ nip: "85",
+ icon: BarChart3,
+ },
+ 30385: {
+ kind: 30385,
+ name: "External Assertion",
+ description: "Trusted Assertion: External Identifier",
+ nip: "85",
+ icon: BarChart3,
+ },
30312: {
kind: 30312,
name: "Interactive Room",
diff --git a/src/lib/nip85-helpers.ts b/src/lib/nip85-helpers.ts
new file mode 100644
index 0000000..dca6626
--- /dev/null
+++ b/src/lib/nip85-helpers.ts
@@ -0,0 +1,262 @@
+import type { NostrEvent } from "@/types/nostr";
+import { getOrComputeCachedValue, getTagValue } from "applesauce-core/helpers";
+
+/**
+ * NIP-85 Helper Functions
+ * Utility functions for parsing NIP-85 Trusted Assertion events
+ *
+ * Kind 30382 - User Assertions (subject: pubkey)
+ * Kind 30383 - Event Assertions (subject: event_id)
+ * Kind 30384 - Addressable Event Assertions (subject: event_address)
+ * Kind 30385 - External Identifier Assertions (subject: NIP-73 i-tag)
+ * Kind 10040 - Trusted Provider List
+ */
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface AssertionTag {
+ name: string;
+ value: string;
+}
+
+export interface UserAssertionData {
+ rank?: number;
+ followers?: number;
+ firstCreatedAt?: number;
+ postCount?: number;
+ replyCount?: number;
+ reactionsCount?: number;
+ zapAmountReceived?: number;
+ zapAmountSent?: number;
+ zapCountReceived?: number;
+ zapCountSent?: number;
+ zapAvgAmountDayReceived?: number;
+ zapAvgAmountDaySent?: number;
+ reportsReceived?: number;
+ reportsSent?: number;
+ topics?: string[];
+ activeHoursStart?: number;
+ activeHoursEnd?: number;
+}
+
+export interface EventAssertionData {
+ rank?: number;
+ commentCount?: number;
+ quoteCount?: number;
+ repostCount?: number;
+ reactionCount?: number;
+ zapCount?: number;
+ zapAmount?: number;
+}
+
+export interface ExternalAssertionData {
+ rank?: number;
+ commentCount?: number;
+ reactionCount?: number;
+}
+
+export interface TrustedProviderEntry {
+ kindTag: string;
+ servicePubkey: string;
+ relay: string;
+}
+
+// ============================================================================
+// Human-readable labels for assertion tags
+// ============================================================================
+
+export const ASSERTION_TAG_LABELS: Record = {
+ rank: "Rank",
+ followers: "Followers",
+ first_created_at: "First Post",
+ post_cnt: "Posts",
+ reply_cnt: "Replies",
+ reactions_cnt: "Reactions",
+ zap_amt_recd: "Zaps Received (sats)",
+ zap_amt_sent: "Zaps Sent (sats)",
+ zap_cnt_recd: "Zaps Received",
+ zap_cnt_sent: "Zaps Sent",
+ zap_avg_amt_day_recd: "Avg Zap/Day Received",
+ zap_avg_amt_day_sent: "Avg Zap/Day Sent",
+ reports_cnt_recd: "Reports Received",
+ reports_cnt_sent: "Reports Sent",
+ active_hours_start: "Active Start (UTC)",
+ active_hours_end: "Active End (UTC)",
+ comment_cnt: "Comments",
+ quote_cnt: "Quotes",
+ repost_cnt: "Reposts",
+ reaction_cnt: "Reactions",
+ zap_cnt: "Zaps",
+ zap_amount: "Zap Amount (sats)",
+};
+
+/** Kind-specific subject type labels */
+export const ASSERTION_KIND_LABELS: Record = {
+ 30382: "User Assertion",
+ 30383: "Event Assertion",
+ 30384: "Address Assertion",
+ 30385: "External Assertion",
+};
+
+// ============================================================================
+// Cache symbols
+// ============================================================================
+
+const AssertionTagsSymbol = Symbol("assertionTags");
+const UserAssertionDataSymbol = Symbol("userAssertionData");
+const EventAssertionDataSymbol = Symbol("eventAssertionData");
+const ExternalAssertionDataSymbol = Symbol("externalAssertionData");
+const TrustedProvidersSymbol = Symbol("trustedProviders");
+
+// Tags that are structural, not result data
+const STRUCTURAL_TAGS = new Set(["d", "p", "e", "a", "k"]);
+
+// ============================================================================
+// Shared Helpers
+// ============================================================================
+
+/**
+ * Get the subject of the assertion (d tag value)
+ * - Kind 30382: pubkey
+ * - Kind 30383: event_id
+ * - Kind 30384: event_address (kind:pubkey:d-tag)
+ * - Kind 30385: NIP-73 identifier
+ */
+export function getAssertionSubject(event: NostrEvent): string | undefined {
+ return getTagValue(event, "d");
+}
+
+/**
+ * Get all result tags (non-structural tags) as AssertionTag[]
+ */
+export function getAssertionTags(event: NostrEvent): AssertionTag[] {
+ return getOrComputeCachedValue(event, AssertionTagsSymbol, () =>
+ event.tags
+ .filter((t) => !STRUCTURAL_TAGS.has(t[0]) && t[1] !== undefined)
+ .map((t) => ({ name: t[0], value: t[1] })),
+ );
+}
+
+// ============================================================================
+// Kind 30382: User Assertion Helpers
+// ============================================================================
+
+function parseIntTag(event: NostrEvent, tag: string): number | undefined {
+ const val = getTagValue(event, tag);
+ if (val === undefined) return undefined;
+ const n = parseInt(val, 10);
+ return isNaN(n) ? undefined : n;
+}
+
+/**
+ * Get full parsed user assertion data (cached)
+ */
+export function getUserAssertionData(event: NostrEvent): UserAssertionData {
+ return getOrComputeCachedValue(event, UserAssertionDataSymbol, () => {
+ const topics = event.tags
+ .filter((t) => t[0] === "t" && t[1])
+ .map((t) => t[1]);
+
+ return {
+ rank: parseIntTag(event, "rank"),
+ followers: parseIntTag(event, "followers"),
+ firstCreatedAt: parseIntTag(event, "first_created_at"),
+ postCount: parseIntTag(event, "post_cnt"),
+ replyCount: parseIntTag(event, "reply_cnt"),
+ reactionsCount: parseIntTag(event, "reactions_cnt"),
+ zapAmountReceived: parseIntTag(event, "zap_amt_recd"),
+ zapAmountSent: parseIntTag(event, "zap_amt_sent"),
+ zapCountReceived: parseIntTag(event, "zap_cnt_recd"),
+ zapCountSent: parseIntTag(event, "zap_cnt_sent"),
+ zapAvgAmountDayReceived: parseIntTag(event, "zap_avg_amt_day_recd"),
+ zapAvgAmountDaySent: parseIntTag(event, "zap_avg_amt_day_sent"),
+ reportsReceived: parseIntTag(event, "reports_cnt_recd"),
+ reportsSent: parseIntTag(event, "reports_cnt_sent"),
+ topics: topics.length > 0 ? topics : undefined,
+ activeHoursStart: parseIntTag(event, "active_hours_start"),
+ activeHoursEnd: parseIntTag(event, "active_hours_end"),
+ };
+ });
+}
+
+// ============================================================================
+// Kind 30383 / 30384: Event & Address Assertion Helpers
+// ============================================================================
+
+/**
+ * Get full parsed event/address assertion data (cached)
+ */
+export function getEventAssertionData(event: NostrEvent): EventAssertionData {
+ return getOrComputeCachedValue(event, EventAssertionDataSymbol, () => ({
+ rank: parseIntTag(event, "rank"),
+ commentCount: parseIntTag(event, "comment_cnt"),
+ quoteCount: parseIntTag(event, "quote_cnt"),
+ repostCount: parseIntTag(event, "repost_cnt"),
+ reactionCount: parseIntTag(event, "reaction_cnt"),
+ zapCount: parseIntTag(event, "zap_cnt"),
+ zapAmount: parseIntTag(event, "zap_amount"),
+ }));
+}
+
+// ============================================================================
+// Kind 30385: External Assertion Helpers
+// ============================================================================
+
+/**
+ * Get full parsed external assertion data (cached)
+ */
+export function getExternalAssertionData(
+ event: NostrEvent,
+): ExternalAssertionData {
+ return getOrComputeCachedValue(event, ExternalAssertionDataSymbol, () => ({
+ rank: parseIntTag(event, "rank"),
+ commentCount: parseIntTag(event, "comment_cnt"),
+ reactionCount: parseIntTag(event, "reaction_cnt"),
+ }));
+}
+
+/**
+ * Get NIP-73 k tags (type identifiers for external subjects)
+ */
+export function getExternalAssertionTypes(event: NostrEvent): string[] {
+ return event.tags.filter((t) => t[0] === "k" && t[1]).map((t) => t[1]);
+}
+
+// ============================================================================
+// Kind 10040: Trusted Provider List Helpers
+// ============================================================================
+
+/**
+ * Get public trusted provider entries from tags (cached)
+ */
+export function getTrustedProviders(event: NostrEvent): TrustedProviderEntry[] {
+ return getOrComputeCachedValue(event, TrustedProvidersSymbol, () =>
+ event.tags
+ .filter((t) => t[0].includes(":") && t[1] && t[2])
+ .map((t) => ({
+ kindTag: t[0],
+ servicePubkey: t[1],
+ relay: t[2],
+ })),
+ );
+}
+
+/**
+ * Check if the event has encrypted provider entries
+ */
+export function hasEncryptedProviders(event: NostrEvent): boolean {
+ return event.content !== undefined && event.content.trim().length > 0;
+}
+
+/**
+ * Format a kind:tag string for display (e.g., "30382:rank" → "User: Rank")
+ */
+export function formatKindTag(kindTag: string): string {
+ const [kindStr, tag] = kindTag.split(":");
+ const kind = parseInt(kindStr, 10);
+ const kindLabel = ASSERTION_KIND_LABELS[kind] || `Kind ${kind}`;
+ const tagLabel = tag ? ASSERTION_TAG_LABELS[tag] || tag : "";
+ return tagLabel ? `${kindLabel}: ${tagLabel}` : kindLabel;
+}